From 08bf2c27b29b952f7b9fa675fc10cc98e85bfd1c Mon Sep 17 00:00:00 2001 From: Chase Adams Date: Fri, 10 Jul 2026 12:07:42 -0700 Subject: [PATCH] chore: update release skill for perf docs: add Zero 1.8 release notes docs: update Zero 1.8 release notes chore: work chore: fixup chore: further updates chore: no thanking matt chore: rework docs: finalize Zero 1.8 release materials docs: clarify ordered query example chore: update writing chore: work chore: testing backup chore: release chore: cleanup --- .agents/skills/release-notes/SKILL.md | 404 ++++-- .agents/skills/release-smoke-test/SKILL.md | 208 +++ .gitignore | 6 +- assets/search-index.json | 1418 +++++++++++--------- components/BenchmarkComparisonChart.tsx | 79 +- components/IntroductionLanding.tsx | 4 +- components/search.tsx | 4 + components/ui/table.tsx | 2 +- contents/docs/mutators.mdx | 27 +- contents/docs/otel.mdx | 163 ++- contents/docs/postgres-support.mdx | 4 +- contents/docs/release-notes/1.8.mdx | 171 +++ contents/docs/release-notes/index.mdx | 1 + contents/docs/self-host.mdx | 7 + contents/docs/zero-cache-config.mdx | 32 +- lib/mdx.ts | 3 + lib/search.ts | 246 +++- tests/search.test.ts | 242 +++- 18 files changed, 2106 insertions(+), 915 deletions(-) create mode 100644 .agents/skills/release-smoke-test/SKILL.md create mode 100644 contents/docs/release-notes/1.8.mdx diff --git a/.agents/skills/release-notes/SKILL.md b/.agents/skills/release-notes/SKILL.md index a8a63984..2dccb0cc 100644 --- a/.agents/skills/release-notes/SKILL.md +++ b/.agents/skills/release-notes/SKILL.md @@ -1,155 +1,269 @@ --- name: release-notes -description: Draft Zero release notes from rocicorp/mono commits, using the zero-docs release-notes format with over-inclusive feature/fix capture, protocol compatibility checks, and breaking-change detection. +description: Draft Zero release notes from rocicorp/mono commits, including complete commit audits, compatibility and breaking-change checks, substantiated performance claims, related docs updates, and smoke-test handoff. --- -# Zero Release Notes Skill +# Zero Release Notes -Use this skill to draft new Zero release notes in `zero-docs` from `rocicorp/mono` commits. +Use this skill to turn a Zero release range into a complete internal audit and concise, accurate public release notes. -## Goal +## Core Contract -Produce a release note draft that is intentionally over-inclusive so a human can trim it. +1. Audit exhaustively. Every commit in the release range gets a recorded decision. +2. Select deliberately. Publish changes that developers or operators can observe; record why other candidates were omitted. +3. Verify before claiming. Public statements must trace to source, tests, product docs, issues, or repeatable benchmarks. +4. Write simply. Explain the developer-facing behavior at the minimum useful technical level. +5. Preserve state. Save refs, commands, decisions, evidence, and unresolved questions under `.releases/./`. + +The private audit should be over-inclusive. The public note should be selective and concise. ## Inputs -- Release version, e.g. `1.2.0` -- Previous release tag, e.g. `zero/v1.1.0` -- Target tag/commit, e.g. `zero/v1.2.0` or `main` - -## Repo Discovery - -1. Look for a local monorepo as a peer of the current repo first. -2. Preferred path check order: - - `../mono` - - other sibling dirs named like `*mono*` -3. Verify it is the right repo by checking that `origin` is `rocicorp/mono`. -4. If no valid local monorepo is found, ask the human for the path. -5. Only use remote GitHub fallback if local repo is unavailable. - -## Workflow - -1. Determine commit range between release tags: - - First list every non-merge commit in the raw range: - - `git log --reverse --oneline --no-merges ..` - - Keep this as the audit source until the human has reviewed categorization. -2. Remove commits already included in the previous release through cherry-picks: - - Find the common ancestor between previous release and target: - - `git merge-base ` - - Inspect commits on the previous-release side after that ancestor: - - `git log --reverse --format='%h%x09%s%n%b%n---END---' ..` - - Look for `-x` cherry-pick trailers such as `(cherry picked from commit )`. - - Also use patch-equivalence to catch cherry-picks whose lockfiles or package-manager files differ: - - `git log --right-only --no-merges --cherry-mark --format='%m%x09%h%x09%s' ...` - - Treat `=` commits as already present in the previous release and categorize them as `skip`, unless the target commit contains materially different user-facing changes. - - When a previous-release maintenance commit names a target commit in its cherry-pick trailer, categorize that target commit as `skip`. -3. Run the protocol compatibility check immediately: - - Find protocol constants in mono before drafting any notes. - - Check both previous release and target values, for example: - - `git show :packages/zero-protocol/src/protocol-version.ts` - - `git show :packages/zero-protocol/src/protocol-version.ts` - - Ensure the target version's minimum supported sync protocol is `<=` the previous release's `PROTOCOL_VERSION`. - - If compatibility fails, this is a release-blocking breaking change. Record it loudly in `.releases/./commits.md` with `BREAKING`, and mark the responsible commit `BREAKING` if it can be identified. - - If compatibility passes, still record the result in `.releases/./commits.md` so later release reviews do not need to rediscover it. -4. Categorize every commit and flag potential breaking changes before drafting. Use only these categories: - - `perf` - - `feature` - - `fix` - - `skip` - - Also identify potential breaking changes in every commit, including skipped/internal-looking commits. - - Look early for API renames/removals, env var/config changes, default behavior flips, migration requirements, protocol changes, package export/import changes, dependency/peer dependency changes that can affect install/runtime behavior, and commit text containing "breaking". - - Record breaking-change status separately from category. - - Use `-` for commits that are not believed breaking. - - Use `MAYBE` for commits that could be breaking and need human review; explain why in the note. - - If a commit is believed to be breaking, make it extremely visible with `BREAKING`. This should be rare; Zero release planning aims to avoid breaking changes. - - Treat breaking-change detection as an early warning system for ongoing release review, not something to defer until the final draft. -5. Present the categorization to the human for review before writing release notes: - - Use a Markdown table with `Commit`, `Category`, `Breaking?`, and `Note`. - - The note should be a few sentences when useful: summarize what changed, why the category was chosen, whether it was skipped due to cherry-pick, revert, internal-only scope, or lack of user-facing impact, and why it is or is not a potential breaking change. - - Prefer `skip` for CI, release tooling, benchmark-only, sample-only, dependency hygiene with no identified user-facing effect, reverted changes, and commits already in the previous release. - - Use `fix` for customer-observable behavior even when the commit is labeled `chore`, e.g. packaging changes that prevent duplicate runtime dependencies from breaking checks like `Pool instanceof`. - - Use `perf` for fixes whose primary user-facing value is measured speed/CPU/allocation improvement. - - Use `feature` for new user/operator/debugging capability. - - Reclassify suspicious commits while building the table: - - Include `chore` commits that look user-facing, behavior-changing, protocol-affecting, package/export-affecting, or crash/fix related. - - Check dependency update commits when they affect runtime, install, protocol, query correctness, or performance-critical packages. Look at upstream changelogs when needed. - - Treat the `Breaking?` column as the breaking-change pass: - - Look for API renames/removals, env var/config changes, behavior flips, migration requirements, protocol changes, package export/import changes, dependency/peer dependency changes, and semantically breaking behavior even if unlabeled. - - Also scan commit text for "breaking". - - Flag performance follow-ups early for commits that change query compilation, index use, dependency implementations, hot loops, or runtime semantics. Put the performance concern in the note or open questions even if the commit category is `fix`. -6. Save the reviewed categorization in the docs worktree before drafting: - - Use a stable release working-state directory under `.releases/./`, for example `.releases/1.7/commits.md`. - - Include release version, previous tag, target, merge base, the exact commands used, the reviewed table, potential breaking changes, and any unresolved questions. - - On later sessions, read this file first and evolve it instead of redoing the whole commit audit. -7. After human review of the categorization, classify the non-skipped commits using conventional commit prefixes as a starting point: - - `feat` -> Features - - `fix` -> Fixes - - `perf` -> Performance (if meaningful) - - `chore` -> ignored by default -8. Before drafting, revisit the reviewed table: - - Confirm all non-skipped commits are represented or intentionally omitted. - - Re-check any `MAYBE` or `BREAKING` rows and summarize the decision in the draft or in the saved release state. - - Re-check any performance follow-ups recorded in `.releases/./commits.md`. -9. Build draft release notes in the latest format used in this repo: - - Before drafting, read `contents/docs/release-notes/0.26.mdx` as the canonical long-form style reference to avoid format drift. - - Frontmatter with `title` and `description` - - `## Installation` - - optional `## Overview` - - `## Features` - - optional `## Performance` - - `## Fixes` - - `## Breaking Changes` - -## Formatting Rules - -- Prefer including too much over too little. -- Do not list chores unless they appear miscategorized and user-relevant. -- Feature bullets must link to docs; if unknown, use `TODO` links as placeholders. -- Fix bullets must be one line each and link to PRs. -- Performance bullets should include quantified impact when available (e.g. `2x faster`, `20-30% faster`, `~6-8% faster`) based on PR benchmark tables/comments. -- If a perf PR has mixed results, emphasize meaningful wins and avoid dismissive phrasing. -- If several PRs comprise one logical fix, include one bullet with artful multi-link phrasing. -- If no breaking changes, write `None.` -- **Fix descriptions must be user-facing**, not implementation details: - - Describe the problem, not "Fix [problem]" - the section heading already says "Fixes" - - Phrase fixes as the old broken behavior or user-visible problem, not as a new capability - - Prefer wording like "X could...", "X were not...", "X failed to...", or "X incorrectly..." - - Avoid feature-style wording in fixes such as "X can now...", "Added support for...", or "Expose..." - - When a fix has a user-visible error message and the exact text is available in commit messages, PR discussion, tests, issues, or code comments, prefer quoting that error text because users are more likely to recognize it - - If you cannot verify the exact error text from source material, do not invent or paraphrase it as a fake quote - - Good: "Hang during initial sync when no upstream changes occurred after backfill" - - Good: "Query and mutator validation errors were not exposing raw schema issues in error `details`" - - Good: "Error 'cannot extract elements from a scalar' when nullable array values were passed to custom mutators" - - Bad: "Ensure backfill-completed tx version is >= the backfill watermark" - - Bad: "Query and mutator validation errors can expose raw schema issues in error `details`" - - Bad: "Error 'some guessed message' when..." if that string was not verified from source - - If there's an error message, include it: "Error 'could not frob confabulator' when..." - - Omit purely internal fixes that users would never notice -- Thank external contributors (non-Rocicorp) at the end of their bullet: - - Format: `(thanks [@username](https://github.com/username)!)` - - Check commit author emails - rocicorp employees use `@roci.dev` emails - - Also check for Co-authored-by lines in commit messages - -## File Updates - -1. Add new note at `contents/docs/release-notes/..mdx`. -2. Add index entry at the top of `contents/docs/release-notes/index.mdx`: - - Format: `- [Zero X.Y: Short Description](/docs/release-notes/X.Y)` - - Insert as the first list item (after the frontmatter) - - The description should match the `description` field in the release note's frontmatter -3. Keep title style aligned with latest release notes. - -## Updating Non-Release-Note Docs - -When a new feature lands, the release-notes feature bullet should link to a real docs anchor — not a PR. That often means updating `contents/docs/**` to document the feature alongside the release. - -When updating those docs, do not mention version numbers. The main docs describe Zero's current state only; version numbers belong in release notes. Exception: a `` callout describing historical behavior or a legacy workaround may say "originally" or "previously" but should still avoid precise version numbers like `>=v1.5`. - -## Output Checklist - -- Commit range used -- Features included -- Performance items included/excluded rationale -- Potential breaking changes list (or explicit none found) -- Protocol compatibility result -- Any TODO docs links left for human follow-up +Collect or infer: + +- Release version, such as `1.8.0`. +- Previous immutable tag or ref. +- Target immutable tag, branch, or commit. +- Local `rocicorp/mono` repository path. +- Local `zero-docs` repository path. + +Resolve both refs to exact SHAs before auditing. Ask one short question if a required input is ambiguous. + +## Source Provenance + +1. Prefer a local monorepo and verify that its remote points to `rocicorp/mono`. +2. Record the previous ref, target ref, resolved SHAs, and merge base. +3. Save the complete non-merge range as the audit source: + +```sh +git log --reverse --oneline --no-merges .. +``` + +4. Detect changes already shipped through maintenance backports. +5. Inspect cherry-pick trailers on the previous-release side of the merge base. +6. Use patch equivalence to find backports whose generated or lockfile changes differ: + +```sh +git log --right-only --no-merges --cherry-mark --format='%m%x09%h%x09%s' ... +``` + +Treat patch-equivalent commits and commits named by `cherry-pick -x` trailers as already shipped unless the target contains additional user-visible behavior. + +## Release Audit + +Create or update `.releases/./commits.md`. Do not redo completed research in later sessions; evolve this file. + +Record: + +- Release refs, SHAs, merge base, and commands used. +- Protocol compatibility result. +- A row for every commit in the raw range. +- Potential breaking changes and their resolution. +- Performance candidates, benchmark status, and whether coverage is original-suite or targeted/backported. +- Required product documentation. +- Open questions and explicit omission decisions. + +Use this table shape: + +| Commit | Category | Breaking? | Public impact | Decision and evidence | +| ------ | -------- | --------- | ------------- | --------------------- | + +Categories are `feature`, `fix`, `performance`, and `skip`. Commit prefixes are discovery hints, not classification rules. A `chore` can be user-facing; a `fix` can primarily be a performance improvement. + +Use `-`, `MAYBE`, or `BREAKING` in the breaking column. Inspect every commit, including skipped and internal-looking commits. + +Prefer `skip` for reverted work, already-shipped backports, CI-only changes, benchmark-only changes, sample-only changes, and dependency hygiene with no identified user impact. Record the reason. + +For dependency updates affecting runtime, installation, protocol behavior, query correctness, or performance-critical paths, inspect the upstream release notes before assigning `skip`. + +Present the completed audit to the human for review before drafting public notes. + +## Safety Gates + +### Protocol Compatibility + +Check protocol constants on both refs before drafting. The target's minimum supported sync protocol must be less than or equal to the previous release's protocol version. + +Record passing and failing results. An incompatible result is release-blocking; mark the responsible audit row `BREAKING` when identifiable. + +### Breaking Changes + +Review the range independently for: + +- Public API, package export, or import changes. +- Configuration or environment-variable changes. +- Default behavior changes. +- Migration or persisted-data requirements. +- Protocol compatibility. +- Dependency and peer-dependency changes affecting install or runtime behavior. +- Deployment, upgrade, or rollback requirements. + +Also inspect commit messages, PR descriptions, and labels for explicit breaking-change declarations. + +Resolve every `MAYBE` before final publication. Breaking changes should include practical migration instructions. + +## Public Candidate Selection + +After audit review: + +1. Select changes visible to application developers, operators, installation, deployment, correctness, reliability, or supported APIs. +2. Group commits that form one coherent capability or solve one user-visible problem. +3. Map each feature to durable product documentation. +4. Map each fix to its PR or issue. +5. Record an explicit omission reason for every non-skipped candidate not included publicly. +6. Confirm every non-skipped commit is represented or intentionally omitted before finalizing. + +## Performance Evidence + +Performance items require release-quality baseline and target evidence. Load the `benchmark-compare` skill to run comparisons; this skill owns claim selection and public presentation, not benchmark-runner mechanics. + +Before benchmarking, define: + +- The developer workload. +- The exact lifecycle phase: hydration, incremental updates, replication, startup, or another operation. +- The claim boundary and whether it requires end-to-end measurement. +- The metric direction and meaningful unit. + +Use `benchmark-compare` for execution. For release-quality evidence, normally require 10 completed process-isolated runs per ref, aggregate process-level medians, classify each result as original-suite or targeted/backported coverage, and save commands, raw output, aggregates, environment, and caveats under `.releases/./`. + +The measurement must support the public claim. Internal-stage throughput cannot substantiate a claim about complete replication, hydration, or incremental updates. + +Use meaningful absolute chart units. Prefer latency for latency, updates per second for incremental maintenance, and payload `MiB/s` for variable-size replication workloads when payload bytes are known. Benchmark artifacts must say whether bytes represent fixture payload, WAL, logical replication, or wire traffic. + +Omit a performance item when the complete operation does not improve meaningfully, the workload cannot be explained, or the evidence does not support a useful developer claim. For mixed results, narrow the claim to the supported workloads and disclose meaningful contrary results affecting the same audience. + +## Draft Structure + +Read the latest reviewed release note for current house style. Use one current exemplar rather than combining rules from several historical releases. + +Write the note to `contents/docs/release-notes/..mdx`. + +Default structure: + +```md +--- +title: Zero X.Y +description: Short Description +--- + +## Installation + +## Overview + +## Features + +## Performance + +## Fixes + +## Breaking Changes +``` + +`Overview` and `Performance` are optional. Always include `Breaking Changes`; write `None.` when there are none. + +## Public Writing + +Write for a developer deciding whether the release matters to their application. + +- Lead with the workload, problem, or capability, not a commit title or subsystem. +- Use the smallest public API concept that identifies the feature. Do not repeat API syntax throughout the explanation. +- Keep code examples minimal while ensuring they can produce the behavior being discussed. +- Use established product and docs terminology. +- Verify implementation details privately, then explain the behavior at the Zero/product level. +- Teach only enough technical detail to explain who benefits and why. +- Do not expose internal class, operator, storage, or benchmark names unless developers use them directly. +- Scope claims to the measured lifecycle phase and conditions. +- Avoid speculative or promotional conclusions not demonstrated by evidence. + +For performance prose, use rounded comparisons such as `about 1.5x faster`, `2x faster`, or `over 50x faster`. Put exact baseline and target values in the chart instead of repeating them in prose. `In benchmarks` is a useful way to scope a result; avoid methodology digressions in public copy. + +When conditions or causality need clarification, use this pattern: + +```md +### Developer Operation + +[Introduce the workload and relevant conditions.] + +[Explain the old and new behavior only when needed to understand the result.] + +In benchmarks, [condition], [operation] was [rounded comparison] faster. + + +``` + +Do not force implementation details such as cursor predicates, planner bounds, or internal pipeline stages into public prose when a simpler behavioral explanation is materially accurate. + +Order performance sections by expected developer impact, not commit order or the largest multiplier. + +## Charts + +Charts carry the concrete evidence while prose carries the takeaway. + +Require: + +- A user-facing operation as the title. +- Plain row labels that identify what varies. +- Absolute baseline and target values. +- A meaningful unit and correct metric direction. +- Minimal machine subtext, such as `Measured on M5 Pro. Higher is better.` +- A compact tooltip with both values and the faster or slower ratio. + +Keep machine names out of body prose, chart titles, and row labels. Keep detailed hardware, process counts, aggregation, and caveats in the release working directory. + +## Features, Fixes, and Attribution + +Feature bullets should explain a developer capability and link to durable product docs. A `TODO` link is acceptable in a draft but not in a publishable note. + +Fix bullets should describe the user-visible problem or corrected behavior and link to the PR. Group related fixes when one sentence is clearer. Use exact error text only when verified from source material. Omit fixes with no observable user or operator effect. + +Thank external contributors at the end of the relevant bullet: + +```md +(thanks [@username](https://github.com/username)!) +``` + +Verify commit authors, co-authors, and organizational affiliation rather than relying on commit prefixes or individual-specific exceptions. + +## Product Docs + +Features should link to real docs anchors, which may require updating `contents/docs/**` alongside the release note. + +Main product docs describe the current product and should normally avoid release version numbers. Historical version context belongs in release notes unless a clearly historical callout is necessary. + +Insert `- [Zero X.Y: ](/docs/release-notes/X.Y)` as the first item in `contents/docs/release-notes/index.mdx`; `` must match the release-note frontmatter. + +## Final Verification + +Before presenting the final draft, confirm: + +- Every audited commit has a decision. +- Every public item maps to evidence. +- Every performance claim maps to saved benchmark results. +- Performance prose is no broader than its chart. +- Protocol compatibility is recorded. +- Breaking risks are resolved. +- No draft TODO links remain. +- Required product docs and release index are updated. +- Generated docs artifacts are current. +- Formatting, types, tests, and build pass where applicable. + +Review headings, prose, code examples, chart titles, row labels, subtext, and tooltips independently. The result should be concise without hiding scope or uncertainty. + +## Smoke-Test Handoff + +After editorial review and canary availability, load the `release-smoke-test` skill. Pass it the reviewed notes, previous stable version, target canary, mono ref, companion-package scope, and unresolved risks from the release audit. + +Treat the release notes as the upgrade contract. Any required migration or operational step that cannot be inferred from the notes is a documentation gap and should be fed back into the release note or product docs. + +## Output + +Report: + +- Release refs and SHAs. +- Audit and compatibility status. +- Features, fixes, and breaking changes included. +- Intentional omissions and unresolved questions. +- Performance items included or excluded, with evidence locations. +- Product docs and generated files updated. +- Validation results. +- Smoke-test readiness or blockers. diff --git a/.agents/skills/release-smoke-test/SKILL.md b/.agents/skills/release-smoke-test/SKILL.md new file mode 100644 index 00000000..9169a1d9 --- /dev/null +++ b/.agents/skills/release-smoke-test/SKILL.md @@ -0,0 +1,208 @@ +--- +name: release-smoke-test +description: Plan and run Zero canary smoke tests across maintained sample apps, companion packages, Docker images, and internal rollout or rollback. Use when preparing a Zero release, upgrading examples to a canary, testing drizzle-zero or prisma-zero, or coordinating manual app validation. +--- + +# Zero Release Smoke Test + +Use this skill after the release contents are understood and a canary is available. It coordinates published-artifact validation, sample-app upgrades, feature-focused manual testing, companion-package tests, and internal rollout or rollback. + +## Goal + +Prove that a Zero release can be installed, integrated, operated, and rolled back through representative public and internal applications. Use the process to find runtime defects, accidental breaking changes, awkward APIs, packaging problems, missing release notes, and incomplete product documentation before the final release. + +## Inputs + +Collect or infer: + +- Previous stable Zero version. +- Target canary version. +- Intended final version, if known. +- Target release notes and release working-state directory. +- `rocicorp/mono` target ref. +- Parent directory where Rocicorp repositories should be checked out. +- Maintained sample and companion-package scope. +- Native platforms available for manual testing. +- Human owners for internal rollout and manual app checks. + +Ask one short question for any input that materially changes scope or makes a test impossible. Do not block mechanical discovery on optional inputs. + +## Upgrade Contract + +1. Treat the target release notes as the upgrade contract. +2. Do not read older release notes, migration guides, PR descriptions, or unrelated product docs unless the human permits it. +3. Repository source, manifests, lockfiles, generated files, package metadata, compiler errors, tests, and runtime diagnostics are valid evidence. +4. Record any required migration that cannot be inferred from the target release notes as a release-note or documentation gap. +5. Do not silently add compatibility workarounds. Prefer fixing the canary or documenting a real migration requirement. + +## Repository Discovery + +1. Inventory existing sibling repositories before cloning. +2. Inspect the Rocicorp organization repository list when the local inventory is incomplete. +3. Classify each candidate as one of: + - Maintained sample, demo, quickstart, or onboarding repository. + - Companion package with Zero integration tests, such as schema generators or UI helpers. + - Internal dogfood or deployment stack. + - Production product rather than a sample. + - Legacy or inactive sample. + - Archived repository. +4. Include maintained samples and selected companion packages by default. +5. Ask the human before including ambiguous production, legacy, inactive, or archived repositories. +6. Exclude repositories that do not consume the published canary when the goal is package validation. They may still be useful as source-level regression surfaces, but label that distinction. +7. Clone missing included repositories into the configured parent directory. Use each repository's default branch unless onboarding is intentionally represented as a branch sequence. +8. Create branches named `0xcadams/` unless the human specifies another owner prefix. + +## Tracking File + +Default to `.releases/./smoke-test.md` in `zero-docs`. If the human prefers an issue or another system, keep the same information there. + +Record: + +- Release metadata, source refs, and artifact availability. +- Included and excluded repositories with reasons. +- Repository, base ref, previous version, target version, branch, and PR link. +- Dependency, override, release-age exclusion, generated-file, lockfile, and image changes. +- Automated install, generation, format, lint, typecheck, unit, integration, build, and package checks. +- Feature-to-test-surface coverage. +- Manual owner, status, observations, and evidence. +- Internal rollout and rollback results. +- Failures, reduced reproductions, release blockers, and documentation gaps. +- Canary rollover history and final-version replacement status. + +Update the file as work happens. Do not mark tests complete based on intent. + +## Artifact Preflight + +Before editing consumers: + +1. Confirm the exact npm canary exists. +2. Inspect its peer dependencies and optional peer metadata. +3. Confirm every required companion version exists. +4. Confirm Docker Hub and GHCR images exist when samples or deployments use them. +5. Record digests where image identity matters. +6. Stop and report a release blocker when a required artifact is missing. + +## Upgrade Rules + +1. Pin the canary exactly. Do not use `^`, `~`, `latest`, or a broad range for release validation. +2. Update every direct dependency declaration, workspace override, package-manager catalog, release-age exclusion, Docker tag, and relevant fixture. +3. Regenerate lockfiles with the repository's declared package manager. +4. Regenerate checked-in schemas or other artifacts through repository scripts rather than manual edits. +5. Upgrade companion packages only when selected for the release test or required by package compatibility. +6. Inspect peer warnings. Do not dismiss them merely because installation succeeds. +7. Keep feature-specific sample changes only when they naturally improve the sample or demonstrate recommended production configuration. +8. Use temporary local instrumentation or a disposable test commit for contrived checks. +9. Do not change unrelated dependencies merely to refresh a lockfile. + +## Test Order + +Use small batches so failures are attributable and humans are not overwhelmed: + +1. Companion packages and their unit or integration fixtures. +2. Onboarding and installation examples. +3. Framework quickstarts. +4. Rich browser applications. +5. Native applications. +6. Internal dogfood and deployment stacks. + +Finish automated checks for a batch before requesting its manual checks. Stop between manual batches for human results when practical. + +## Feature Coverage + +1. Map every feature, breaking change, reliability fix, and high-risk performance change in the target release notes to at least one test surface. +2. Prefer applications already exercising the affected API, adapter, runtime, or deployment mode. +3. Test new APIs for usability, not only compilation. +4. Test fixes by reproducing the old risk when feasible. +5. Include reconnect, restart, stale-data, and offline transitions for sync correctness changes. +6. Include deep, sparse, or boundary-heavy data when validating ordered limited queries. +7. Include a large upstream transaction for replication persistence or flow-control changes. +8. Include native force-quit and relaunch when validating native SQLite behavior. +9. Include telemetry collection and metric inspection for observability changes. +10. Record why an item was not directly testable. + +## Automated Checks + +Infer commands from each repository's manifests and CI. Run applicable checks in this order: + +1. Package-manager install. +2. Generated artifact or schema checks. +3. Formatting and linting. +4. Typechecking. +5. Unit tests. +6. Integration tests. +7. Production build or package build. +8. Focused runtime smoke test. + +Use clean installs when validating package resolution. Preserve package-manager and runtime versions declared by the repository. + +## Manual Test Cards + +For each manual session, give the human a short card containing: + +- Repository and exact branch or commit. +- Required services and startup commands. +- Starting state or seed data. +- Actions to perform in order. +- Expected visible behavior. +- Logs, screenshots, metrics, or device details to capture on failure. +- A clear pass, fail, or blocked response format. + +The agent owns setup, logs, and diagnosis. The human owns visual judgment, API-design judgment, native-device behavior, privileged deployment actions, and final manual pass or fail decisions. + +## Draft PRs + +1. Use one draft PR per repository when possible. +2. Use separate PRs when one repository has independent onboarding branches that must remain individually runnable. +3. Keep PRs draft until automated and assigned manual checks pass. +4. Write PR descriptions for reviewers, using the repository's required format. +5. Include the target canary and relevant feature coverage, but do not dump the whole checklist into every PR. + +## Internal Rollout And Rollback + +1. Select an internal stack that exercises the published package or image. +2. Capture the current version and a basic health baseline. +3. Upgrade to the canary and verify sync, mutations, queries, reconnects, startup, and telemetry. +4. Roll back to the previous version and verify compatibility and recovery. +5. Upgrade to the canary again and repeat the reduced health check. +6. Record timing, errors, data correctness, metrics, and operator observations. +7. Treat rollback failure, protocol incompatibility, data loss, or unrecoverable stale state as a release blocker. + +Never perform a production or CloudZero rollout without explicit human approval. Follow the operational repository's incident and deployment instructions. + +## Fix And Canary Loop + +When testing finds a release issue: + +1. Reduce it to the smallest reliable reproduction. +2. Classify it as a release blocker, documentation gap, sample bug, companion-package issue, or non-blocking observation. +3. Decide with the human whether the fix belongs on trunk or a maintenance branch. +4. Use `git cherry-pick -x` for release-branch cherry-picks. +5. Cut a new canary after the fix. +6. Update every draft sample branch to the new exact canary. +7. Rerun the failed test, affected feature tests, artifact checks, and a reduced cross-app compatibility suite. +8. Record the rollover and why it happened. + +## Finalization + +After the final non-canary release exists: + +1. Replace canary pins with the exact final version. +2. Replace canary image tags with final tags. +3. Regenerate lockfiles and generated artifacts. +4. Rerun automated checks and a reduced manual suite. +5. Update tracking with final results. +6. Merge sample and docs changes only after the release manager confirms promotion timing. +7. Release selected companion packages after their final Zero compatibility pass. + +## Output Checklist + +- Target canary and artifact preflight results. +- Included and excluded repositories with rationale. +- Branch and PR inventory. +- Automated results by repository. +- Manual test cards and human results. +- Feature coverage matrix. +- Internal rollout and rollback result. +- Release blockers and documentation gaps. +- Canary rollover history. +- Final-version replacement status. diff --git a/.gitignore b/.gitignore index 9eb0b445..392c4b06 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,10 @@ npm-debug.log* yarn-debug.log* yarn-error.log* +# release benchmark raw logs +.releases/*/benchmarks/raw/**/*.log +.releases/*/benchmarks/**/raw/**/*.log + # local env files .env*.local @@ -39,4 +43,4 @@ next-env.d.ts **/.claude/settings.local.json # build artifacts -/dist \ No newline at end of file +/dist diff --git a/assets/search-index.json b/assets/search-index.json index aa9189fb..0d4d7415 100644 --- a/assets/search-index.json +++ b/assets/search-index.json @@ -14,7 +14,7 @@ "kind": "page" }, { - "id": "75-agents#options", + "id": "76-agents#options", "title": "Agent Support", "searchTitle": "Options", "sectionTitle": "Options", @@ -126,7 +126,7 @@ "kind": "page" }, { - "id": "76-auth#set-userid-on-client", + "id": "77-auth#set-userid-on-client", "title": "Authentication", "searchTitle": "Set userID on Client", "sectionTitle": "Set userID on Client", @@ -136,7 +136,7 @@ "kind": "section" }, { - "id": "77-auth#define-the-context-type", + "id": "78-auth#define-the-context-type", "title": "Authentication", "searchTitle": "Define the Context Type", "sectionTitle": "Define the Context Type", @@ -146,7 +146,7 @@ "kind": "section" }, { - "id": "78-auth#send-credentials", + "id": "79-auth#send-credentials", "title": "Authentication", "searchTitle": "Send Credentials", "sectionTitle": "Send Credentials", @@ -156,7 +156,7 @@ "kind": "section" }, { - "id": "79-auth#cookies", + "id": "80-auth#cookies", "title": "Authentication", "searchTitle": "Cookies", "sectionTitle": "Cookies", @@ -166,7 +166,7 @@ "kind": "section" }, { - "id": "80-auth#cookie-deployment", + "id": "81-auth#cookie-deployment", "title": "Authentication", "searchTitle": "Cookie Deployment", "sectionTitle": "Cookie Deployment", @@ -176,7 +176,7 @@ "kind": "section" }, { - "id": "81-auth#tokens", + "id": "82-auth#tokens", "title": "Authentication", "searchTitle": "Tokens", "sectionTitle": "Tokens", @@ -186,7 +186,7 @@ "kind": "section" }, { - "id": "82-auth#implement-api-endpoints", + "id": "83-auth#implement-api-endpoints", "title": "Authentication", "searchTitle": "Implement API Endpoints", "sectionTitle": "Implement API Endpoints", @@ -196,7 +196,7 @@ "kind": "section" }, { - "id": "83-auth#query", + "id": "84-auth#query", "title": "Authentication", "searchTitle": "Query", "sectionTitle": "Query", @@ -206,7 +206,7 @@ "kind": "section" }, { - "id": "84-auth#mutate", + "id": "85-auth#mutate", "title": "Authentication", "searchTitle": "Mutate", "sectionTitle": "Mutate", @@ -216,7 +216,7 @@ "kind": "section" }, { - "id": "85-auth#updating-tokens", + "id": "86-auth#updating-tokens", "title": "Authentication", "searchTitle": "Updating Tokens", "sectionTitle": "Updating Tokens", @@ -226,7 +226,7 @@ "kind": "section" }, { - "id": "86-auth#auth-failure-and-refresh", + "id": "87-auth#auth-failure-and-refresh", "title": "Authentication", "searchTitle": "Auth Failure and Refresh", "sectionTitle": "Auth Failure and Refresh", @@ -236,7 +236,7 @@ "kind": "section" }, { - "id": "87-auth#permission-patterns", + "id": "88-auth#permission-patterns", "title": "Authentication", "searchTitle": "Permission Patterns", "sectionTitle": "Permission Patterns", @@ -246,7 +246,7 @@ "kind": "section" }, { - "id": "88-auth#read-permissions", + "id": "89-auth#read-permissions", "title": "Authentication", "searchTitle": "Read Permissions", "sectionTitle": "Read Permissions", @@ -256,7 +256,7 @@ "kind": "section" }, { - "id": "89-auth#only-owned-rows", + "id": "90-auth#only-owned-rows", "title": "Authentication", "searchTitle": "Only Owned Rows", "sectionTitle": "Only Owned Rows", @@ -266,7 +266,7 @@ "kind": "section" }, { - "id": "90-auth#owned-or-shared-rows", + "id": "91-auth#owned-or-shared-rows", "title": "Authentication", "searchTitle": "Owned or Shared Rows", "sectionTitle": "Owned or Shared Rows", @@ -276,7 +276,7 @@ "kind": "section" }, { - "id": "91-auth#owned-rows-or-all-if-admin", + "id": "92-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": "92-auth#deny-by-returning-no-rows", + "id": "93-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": "93-auth#write-permissions", + "id": "94-auth#write-permissions", "title": "Authentication", "searchTitle": "Write Permissions", "sectionTitle": "Write Permissions", @@ -306,7 +306,7 @@ "kind": "section" }, { - "id": "94-auth#enforce-ownership", + "id": "95-auth#enforce-ownership", "title": "Authentication", "searchTitle": "Enforce Ownership", "sectionTitle": "Enforce Ownership", @@ -316,7 +316,7 @@ "kind": "section" }, { - "id": "95-auth#edit-owned-rows", + "id": "96-auth#edit-owned-rows", "title": "Authentication", "searchTitle": "Edit Owned Rows", "sectionTitle": "Edit Owned Rows", @@ -326,7 +326,7 @@ "kind": "section" }, { - "id": "96-auth#edit-owned-or-shared-rows", + "id": "97-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": "97-auth#edit-owned-or-all-if-admin", + "id": "98-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": "98-auth#logging-out", + "id": "99-auth#logging-out", "title": "Authentication", "searchTitle": "Logging Out", "sectionTitle": "Logging Out", @@ -383,7 +383,7 @@ "kind": "page" }, { - "id": "99-community#ui-frameworks", + "id": "100-community#ui-frameworks", "title": "From the Community", "searchTitle": "UI Frameworks", "sectionTitle": "UI Frameworks", @@ -393,7 +393,7 @@ "kind": "section" }, { - "id": "100-community#miscellaneous", + "id": "101-community#miscellaneous", "title": "From the Community", "searchTitle": "Miscellaneous", "sectionTitle": "Miscellaneous", @@ -509,7 +509,7 @@ "kind": "page" }, { - "id": "101-connecting-to-postgres#event-triggers", + "id": "102-connecting-to-postgres#event-triggers", "title": "Connecting to Postgres", "searchTitle": "Event Triggers", "sectionTitle": "Event Triggers", @@ -519,7 +519,7 @@ "kind": "section" }, { - "id": "102-connecting-to-postgres#configuration", + "id": "103-connecting-to-postgres#configuration", "title": "Connecting to Postgres", "searchTitle": "Configuration", "sectionTitle": "Configuration", @@ -529,7 +529,7 @@ "kind": "section" }, { - "id": "103-connecting-to-postgres#wal-level", + "id": "104-connecting-to-postgres#wal-level", "title": "Connecting to Postgres", "searchTitle": "WAL Level", "sectionTitle": "WAL Level", @@ -539,7 +539,7 @@ "kind": "section" }, { - "id": "104-connecting-to-postgres#bounding-wal-size", + "id": "105-connecting-to-postgres#bounding-wal-size", "title": "Connecting to Postgres", "searchTitle": "Bounding WAL Size", "sectionTitle": "Bounding WAL Size", @@ -549,7 +549,7 @@ "kind": "section" }, { - "id": "105-connecting-to-postgres#provider-specific-notes", + "id": "106-connecting-to-postgres#provider-specific-notes", "title": "Connecting to Postgres", "searchTitle": "Provider-Specific Notes", "sectionTitle": "Provider-Specific Notes", @@ -559,7 +559,7 @@ "kind": "section" }, { - "id": "106-connecting-to-postgres#planetscale-for-postgres", + "id": "107-connecting-to-postgres#planetscale-for-postgres", "title": "Connecting to Postgres", "searchTitle": "PlanetScale for Postgres", "sectionTitle": "PlanetScale for Postgres", @@ -569,7 +569,7 @@ "kind": "section" }, { - "id": "107-connecting-to-postgres#roles", + "id": "108-connecting-to-postgres#roles", "title": "Connecting to Postgres", "searchTitle": "Roles", "sectionTitle": "Roles", @@ -579,7 +579,7 @@ "kind": "section" }, { - "id": "108-connecting-to-postgres#connection-limits", + "id": "109-connecting-to-postgres#connection-limits", "title": "Connecting to Postgres", "searchTitle": "Connection Limits", "sectionTitle": "Connection Limits", @@ -589,7 +589,7 @@ "kind": "section" }, { - "id": "109-connecting-to-postgres#pooling", + "id": "110-connecting-to-postgres#pooling", "title": "Connecting to Postgres", "searchTitle": "Pooling", "sectionTitle": "Pooling", @@ -599,7 +599,7 @@ "kind": "section" }, { - "id": "110-connecting-to-postgres#high-availability", + "id": "111-connecting-to-postgres#high-availability", "title": "Connecting to Postgres", "searchTitle": "High Availability", "sectionTitle": "High Availability", @@ -609,7 +609,7 @@ "kind": "section" }, { - "id": "111-connecting-to-postgres#neon", + "id": "112-connecting-to-postgres#neon", "title": "Connecting to Postgres", "searchTitle": "Neon", "sectionTitle": "Neon", @@ -619,7 +619,7 @@ "kind": "section" }, { - "id": "112-connecting-to-postgres#logical-replication", + "id": "113-connecting-to-postgres#logical-replication", "title": "Connecting to Postgres", "searchTitle": "Logical Replication", "sectionTitle": "Logical Replication", @@ -629,7 +629,7 @@ "kind": "section" }, { - "id": "113-connecting-to-postgres#branching", + "id": "114-connecting-to-postgres#branching", "title": "Connecting to Postgres", "searchTitle": "Branching", "sectionTitle": "Branching", @@ -639,7 +639,7 @@ "kind": "section" }, { - "id": "114-connecting-to-postgres#flyio", + "id": "115-connecting-to-postgres#flyio", "title": "Connecting to Postgres", "searchTitle": "Fly.io", "sectionTitle": "Fly.io", @@ -649,7 +649,7 @@ "kind": "section" }, { - "id": "115-connecting-to-postgres#networking", + "id": "116-connecting-to-postgres#networking", "title": "Connecting to Postgres", "searchTitle": "Networking", "sectionTitle": "Networking", @@ -659,7 +659,7 @@ "kind": "section" }, { - "id": "116-connecting-to-postgres#permissions", + "id": "117-connecting-to-postgres#permissions", "title": "Connecting to Postgres", "searchTitle": "Permissions", "sectionTitle": "Permissions", @@ -669,7 +669,7 @@ "kind": "section" }, { - "id": "117-connecting-to-postgres#pooling-1", + "id": "118-connecting-to-postgres#pooling-1", "title": "Connecting to Postgres", "searchTitle": "Pooling", "sectionTitle": "Pooling", @@ -679,7 +679,7 @@ "kind": "section" }, { - "id": "118-connecting-to-postgres#supabase", + "id": "119-connecting-to-postgres#supabase", "title": "Connecting to Postgres", "searchTitle": "Supabase", "sectionTitle": "Supabase", @@ -689,7 +689,7 @@ "kind": "section" }, { - "id": "119-connecting-to-postgres#publication-changes", + "id": "120-connecting-to-postgres#publication-changes", "title": "Connecting to Postgres", "searchTitle": "Publication Changes", "sectionTitle": "Publication Changes", @@ -699,7 +699,7 @@ "kind": "section" }, { - "id": "120-connecting-to-postgres#ipv4", + "id": "121-connecting-to-postgres#ipv4", "title": "Connecting to Postgres", "searchTitle": "IPv4", "sectionTitle": "IPv4", @@ -709,7 +709,7 @@ "kind": "section" }, { - "id": "121-connecting-to-postgres#high-availability-1", + "id": "122-connecting-to-postgres#high-availability-1", "title": "Connecting to Postgres", "searchTitle": "High Availability", "sectionTitle": "High Availability", @@ -719,7 +719,7 @@ "kind": "section" }, { - "id": "122-connecting-to-postgres#render", + "id": "123-connecting-to-postgres#render", "title": "Connecting to Postgres", "searchTitle": "Render", "sectionTitle": "Render", @@ -729,7 +729,7 @@ "kind": "section" }, { - "id": "123-connecting-to-postgres#google-cloud-sql", + "id": "124-connecting-to-postgres#google-cloud-sql", "title": "Connecting to Postgres", "searchTitle": "Google Cloud SQL", "sectionTitle": "Google Cloud SQL", @@ -739,7 +739,7 @@ "kind": "section" }, { - "id": "124-connecting-to-postgres#schema-change-hooks", + "id": "125-connecting-to-postgres#schema-change-hooks", "title": "Connecting to Postgres", "searchTitle": "Schema Change Hooks", "sectionTitle": "Schema Change Hooks", @@ -819,7 +819,7 @@ "kind": "page" }, { - "id": "125-connection#overview", + "id": "126-connection#overview", "title": "Connection Status", "searchTitle": "Overview", "sectionTitle": "Overview", @@ -829,7 +829,7 @@ "kind": "section" }, { - "id": "126-connection#usage", + "id": "127-connection#usage", "title": "Connection Status", "searchTitle": "Usage", "sectionTitle": "Usage", @@ -839,7 +839,7 @@ "kind": "section" }, { - "id": "127-connection#offline", + "id": "128-connection#offline", "title": "Connection Status", "searchTitle": "Offline", "sectionTitle": "Offline", @@ -849,7 +849,7 @@ "kind": "section" }, { - "id": "128-connection#offline-ui", + "id": "129-connection#offline-ui", "title": "Connection Status", "searchTitle": "Offline UI", "sectionTitle": "Offline UI", @@ -859,7 +859,7 @@ "kind": "section" }, { - "id": "129-connection#details", + "id": "130-connection#details", "title": "Connection Status", "searchTitle": "Details", "sectionTitle": "Details", @@ -869,7 +869,7 @@ "kind": "section" }, { - "id": "130-connection#connecting", + "id": "131-connection#connecting", "title": "Connection Status", "searchTitle": "Connecting", "sectionTitle": "Connecting", @@ -879,7 +879,7 @@ "kind": "section" }, { - "id": "131-connection#connected", + "id": "132-connection#connected", "title": "Connection Status", "searchTitle": "Connected", "sectionTitle": "Connected", @@ -889,7 +889,7 @@ "kind": "section" }, { - "id": "132-connection#disconnected", + "id": "133-connection#disconnected", "title": "Connection Status", "searchTitle": "Disconnected", "sectionTitle": "Disconnected", @@ -899,7 +899,7 @@ "kind": "section" }, { - "id": "133-connection#error", + "id": "134-connection#error", "title": "Connection Status", "searchTitle": "Error", "sectionTitle": "Error", @@ -909,7 +909,7 @@ "kind": "section" }, { - "id": "134-connection#needs-auth", + "id": "135-connection#needs-auth", "title": "Connection Status", "searchTitle": "Needs-Auth", "sectionTitle": "Needs-Auth", @@ -919,7 +919,7 @@ "kind": "section" }, { - "id": "135-connection#closed", + "id": "136-connection#closed", "title": "Connection Status", "searchTitle": "Closed", "sectionTitle": "Closed", @@ -929,7 +929,7 @@ "kind": "section" }, { - "id": "136-connection#why-zero-doesnt-support-offline-writes", + "id": "137-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", @@ -939,7 +939,7 @@ "kind": "section" }, { - "id": "137-connection#example", + "id": "138-connection#example", "title": "Connection Status", "searchTitle": "Example", "sectionTitle": "Example", @@ -949,7 +949,7 @@ "kind": "section" }, { - "id": "138-connection#tradeoffs", + "id": "139-connection#tradeoffs", "title": "Connection Status", "searchTitle": "Tradeoffs", "sectionTitle": "Tradeoffs", @@ -959,7 +959,7 @@ "kind": "section" }, { - "id": "139-connection#zeros-position", + "id": "140-connection#zeros-position", "title": "Connection Status", "searchTitle": "Zero's Position", "sectionTitle": "Zero's Position", @@ -1007,7 +1007,7 @@ "kind": "page" }, { - "id": "140-debug/analyze-query-cli#set-up", + "id": "141-debug/analyze-query-cli#set-up", "title": "Analyze Query CLI", "searchTitle": "Set Up", "sectionTitle": "Set Up", @@ -1017,7 +1017,7 @@ "kind": "section" }, { - "id": "141-debug/analyze-query-cli#run-zql-queries", + "id": "142-debug/analyze-query-cli#run-zql-queries", "title": "Analyze Query CLI", "searchTitle": "Run ZQL Queries", "sectionTitle": "Run ZQL Queries", @@ -1027,7 +1027,7 @@ "kind": "section" }, { - "id": "142-debug/analyze-query-cli#production-use", + "id": "143-debug/analyze-query-cli#production-use", "title": "Analyze Query CLI", "searchTitle": "Production Use", "sectionTitle": "Production Use", @@ -1037,7 +1037,7 @@ "kind": "section" }, { - "id": "143-debug/analyze-query-cli#env-var-shorthand", + "id": "144-debug/analyze-query-cli#env-var-shorthand", "title": "Analyze Query CLI", "searchTitle": "Env Var Shorthand", "sectionTitle": "Env Var Shorthand", @@ -1047,7 +1047,7 @@ "kind": "section" }, { - "id": "144-debug/analyze-query-cli#other-input-modes", + "id": "145-debug/analyze-query-cli#other-input-modes", "title": "Analyze Query CLI", "searchTitle": "Other Input Modes", "sectionTitle": "Other Input Modes", @@ -1057,7 +1057,7 @@ "kind": "section" }, { - "id": "145-debug/analyze-query-cli#output", + "id": "146-debug/analyze-query-cli#output", "title": "Analyze Query CLI", "searchTitle": "Output", "sectionTitle": "Output", @@ -1067,7 +1067,7 @@ "kind": "section" }, { - "id": "146-debug/analyze-query-cli#optional-output", + "id": "147-debug/analyze-query-cli#optional-output", "title": "Analyze Query CLI", "searchTitle": "Optional Output", "sectionTitle": "Optional Output", @@ -1127,7 +1127,7 @@ "kind": "page" }, { - "id": "147-debug/inspector#accessing-the-inspector", + "id": "148-debug/inspector#accessing-the-inspector", "title": "Inspector", "searchTitle": "Accessing the Inspector", "sectionTitle": "Accessing the Inspector", @@ -1137,7 +1137,7 @@ "kind": "section" }, { - "id": "148-debug/inspector#clients-and-groups", + "id": "149-debug/inspector#clients-and-groups", "title": "Inspector", "searchTitle": "Clients and Groups", "sectionTitle": "Clients and Groups", @@ -1147,7 +1147,7 @@ "kind": "section" }, { - "id": "149-debug/inspector#queries", + "id": "150-debug/inspector#queries", "title": "Inspector", "searchTitle": "Queries", "sectionTitle": "Queries", @@ -1157,7 +1157,7 @@ "kind": "section" }, { - "id": "150-debug/inspector#analyzing-queries", + "id": "151-debug/inspector#analyzing-queries", "title": "Inspector", "searchTitle": "Analyzing Queries", "sectionTitle": "Analyzing Queries", @@ -1167,7 +1167,7 @@ "kind": "section" }, { - "id": "151-debug/inspector#interpreting-query-analysis", + "id": "152-debug/inspector#interpreting-query-analysis", "title": "Inspector", "searchTitle": "Interpreting Query Analysis", "sectionTitle": "Interpreting Query Analysis", @@ -1177,7 +1177,7 @@ "kind": "section" }, { - "id": "152-debug/inspector#viewing-sqlite-plans", + "id": "153-debug/inspector#viewing-sqlite-plans", "title": "Inspector", "searchTitle": "Viewing SQLite Plans", "sectionTitle": "Viewing SQLite Plans", @@ -1187,7 +1187,7 @@ "kind": "section" }, { - "id": "153-debug/inspector#viewing-zero-plans", + "id": "154-debug/inspector#viewing-zero-plans", "title": "Inspector", "searchTitle": "Viewing Zero Plans", "sectionTitle": "Viewing Zero Plans", @@ -1197,7 +1197,7 @@ "kind": "section" }, { - "id": "154-debug/inspector#analyzing-arbitrary-zql", + "id": "155-debug/inspector#analyzing-arbitrary-zql", "title": "Inspector", "searchTitle": "Analyzing Arbitrary ZQL", "sectionTitle": "Analyzing Arbitrary ZQL", @@ -1207,7 +1207,7 @@ "kind": "section" }, { - "id": "155-debug/inspector#table-data", + "id": "156-debug/inspector#table-data", "title": "Inspector", "searchTitle": "Table Data", "sectionTitle": "Table Data", @@ -1217,7 +1217,7 @@ "kind": "section" }, { - "id": "156-debug/inspector#server-version", + "id": "157-debug/inspector#server-version", "title": "Inspector", "searchTitle": "Server Version", "sectionTitle": "Server Version", @@ -1258,7 +1258,7 @@ "kind": "page" }, { - "id": "157-debug/replication#resetting", + "id": "158-debug/replication#resetting", "title": "Replication", "searchTitle": "Resetting", "sectionTitle": "Resetting", @@ -1268,7 +1268,7 @@ "kind": "section" }, { - "id": "158-debug/replication#inspecting", + "id": "159-debug/replication#inspecting", "title": "Replication", "searchTitle": "Inspecting", "sectionTitle": "Inspecting", @@ -1278,7 +1278,7 @@ "kind": "section" }, { - "id": "159-debug/replication#miscellaneous", + "id": "160-debug/replication#miscellaneous", "title": "Replication", "searchTitle": "Miscellaneous", "sectionTitle": "Miscellaneous", @@ -1318,7 +1318,7 @@ "kind": "page" }, { - "id": "160-debug/slow-queries#analyze-queries", + "id": "161-debug/slow-queries#analyze-queries", "title": "Slow Queries", "searchTitle": "Analyze Queries", "sectionTitle": "Analyze Queries", @@ -1328,7 +1328,7 @@ "kind": "section" }, { - "id": "161-debug/slow-queries#check-ttl", + "id": "162-debug/slow-queries#check-ttl", "title": "Slow Queries", "searchTitle": "Check ttl", "sectionTitle": "Check ttl", @@ -1338,7 +1338,7 @@ "kind": "section" }, { - "id": "162-debug/slow-queries#locality", + "id": "163-debug/slow-queries#locality", "title": "Slow Queries", "searchTitle": "Locality", "sectionTitle": "Locality", @@ -1348,7 +1348,7 @@ "kind": "section" }, { - "id": "163-debug/slow-queries#check-storage", + "id": "164-debug/slow-queries#check-storage", "title": "Slow Queries", "searchTitle": "Check Storage", "sectionTitle": "Check Storage", @@ -1358,7 +1358,7 @@ "kind": "section" }, { - "id": "164-debug/slow-queries#statz", + "id": "165-debug/slow-queries#statz", "title": "Slow Queries", "searchTitle": "/statz", "sectionTitle": "/statz", @@ -1391,7 +1391,7 @@ "kind": "page" }, { - "id": "165-deprecated/ad-hoc-queries#overview", + "id": "166-deprecated/ad-hoc-queries#overview", "title": "Ad-Hoc Queries (Deprecated)", "searchTitle": "Overview", "sectionTitle": "Overview", @@ -1415,7 +1415,7 @@ "kind": "page" }, { - "id": "166-deprecated/crud-mutators#overview", + "id": "167-deprecated/crud-mutators#overview", "title": "CRUD Mutators (Deprecated)", "searchTitle": "Overview", "sectionTitle": "Overview", @@ -1487,7 +1487,7 @@ "kind": "page" }, { - "id": "167-deprecated/rls-permissions#define-permissions", + "id": "168-deprecated/rls-permissions#define-permissions", "title": "RLS Permissions (Deprecated)", "searchTitle": "Define Permissions", "sectionTitle": "Define Permissions", @@ -1497,7 +1497,7 @@ "kind": "section" }, { - "id": "168-deprecated/rls-permissions#access-is-denied-by-default", + "id": "169-deprecated/rls-permissions#access-is-denied-by-default", "title": "RLS Permissions (Deprecated)", "searchTitle": "Access is Denied by Default", "sectionTitle": "Access is Denied by Default", @@ -1507,7 +1507,7 @@ "kind": "section" }, { - "id": "169-deprecated/rls-permissions#permission-evaluation", + "id": "170-deprecated/rls-permissions#permission-evaluation", "title": "RLS Permissions (Deprecated)", "searchTitle": "Permission Evaluation", "sectionTitle": "Permission Evaluation", @@ -1517,7 +1517,7 @@ "kind": "section" }, { - "id": "170-deprecated/rls-permissions#permission-deployment", + "id": "171-deprecated/rls-permissions#permission-deployment", "title": "RLS Permissions (Deprecated)", "searchTitle": "Permission Deployment", "sectionTitle": "Permission Deployment", @@ -1527,7 +1527,7 @@ "kind": "section" }, { - "id": "171-deprecated/rls-permissions#rules", + "id": "172-deprecated/rls-permissions#rules", "title": "RLS Permissions (Deprecated)", "searchTitle": "Rules", "sectionTitle": "Rules", @@ -1537,7 +1537,7 @@ "kind": "section" }, { - "id": "172-deprecated/rls-permissions#select-permissions", + "id": "173-deprecated/rls-permissions#select-permissions", "title": "RLS Permissions (Deprecated)", "searchTitle": "Select Permissions", "sectionTitle": "Select Permissions", @@ -1547,7 +1547,7 @@ "kind": "section" }, { - "id": "173-deprecated/rls-permissions#insert-permissions", + "id": "174-deprecated/rls-permissions#insert-permissions", "title": "RLS Permissions (Deprecated)", "searchTitle": "Insert Permissions", "sectionTitle": "Insert Permissions", @@ -1557,7 +1557,7 @@ "kind": "section" }, { - "id": "174-deprecated/rls-permissions#update-permissions", + "id": "175-deprecated/rls-permissions#update-permissions", "title": "RLS Permissions (Deprecated)", "searchTitle": "Update Permissions", "sectionTitle": "Update Permissions", @@ -1567,7 +1567,7 @@ "kind": "section" }, { - "id": "175-deprecated/rls-permissions#delete-permissions", + "id": "176-deprecated/rls-permissions#delete-permissions", "title": "RLS Permissions (Deprecated)", "searchTitle": "Delete Permissions", "sectionTitle": "Delete Permissions", @@ -1577,7 +1577,7 @@ "kind": "section" }, { - "id": "176-deprecated/rls-permissions#permissions-based-on-auth-data", + "id": "177-deprecated/rls-permissions#permissions-based-on-auth-data", "title": "RLS Permissions (Deprecated)", "searchTitle": "Permissions Based on Auth Data", "sectionTitle": "Permissions Based on Auth Data", @@ -1587,7 +1587,7 @@ "kind": "section" }, { - "id": "177-deprecated/rls-permissions#debugging", + "id": "178-deprecated/rls-permissions#debugging", "title": "RLS Permissions (Deprecated)", "searchTitle": "Debugging", "sectionTitle": "Debugging", @@ -1597,7 +1597,7 @@ "kind": "section" }, { - "id": "178-deprecated/rls-permissions#read-permissions", + "id": "179-deprecated/rls-permissions#read-permissions", "title": "RLS Permissions (Deprecated)", "searchTitle": "Read Permissions", "sectionTitle": "Read Permissions", @@ -1607,7 +1607,7 @@ "kind": "section" }, { - "id": "179-deprecated/rls-permissions#write-permissions", + "id": "180-deprecated/rls-permissions#write-permissions", "title": "RLS Permissions (Deprecated)", "searchTitle": "Write Permissions", "sectionTitle": "Write Permissions", @@ -1687,7 +1687,7 @@ "kind": "page" }, { - "id": "180-install#integrate-zero", + "id": "181-install#integrate-zero", "title": "Install Zero", "searchTitle": "Integrate Zero", "sectionTitle": "Integrate Zero", @@ -1697,7 +1697,7 @@ "kind": "section" }, { - "id": "181-install#set-up-your-database", + "id": "182-install#set-up-your-database", "title": "Install Zero", "searchTitle": "Set Up Your Database", "sectionTitle": "Set Up Your Database", @@ -1707,7 +1707,7 @@ "kind": "section" }, { - "id": "182-install#install-zero", + "id": "183-install#install-zero", "title": "Install Zero", "searchTitle": "Install Zero", "sectionTitle": "Install Zero", @@ -1717,7 +1717,7 @@ "kind": "section" }, { - "id": "183-install#set-up-your-zero-schema", + "id": "184-install#set-up-your-zero-schema", "title": "Install Zero", "searchTitle": "Set Up Your Zero Schema", "sectionTitle": "Set Up Your Zero Schema", @@ -1727,7 +1727,7 @@ "kind": "section" }, { - "id": "184-install#set-up-the-zero-client", + "id": "185-install#set-up-the-zero-client", "title": "Install Zero", "searchTitle": "Set Up the Zero Client", "sectionTitle": "Set Up the Zero Client", @@ -1737,7 +1737,7 @@ "kind": "section" }, { - "id": "185-install#sync-data", + "id": "186-install#sync-data", "title": "Install Zero", "searchTitle": "Sync Data", "sectionTitle": "Sync Data", @@ -1747,7 +1747,7 @@ "kind": "section" }, { - "id": "186-install#define-query", + "id": "187-install#define-query", "title": "Install Zero", "searchTitle": "Define Query", "sectionTitle": "Define Query", @@ -1757,7 +1757,7 @@ "kind": "section" }, { - "id": "187-install#add-query-endpoint", + "id": "188-install#add-query-endpoint", "title": "Install Zero", "searchTitle": "Add Query Endpoint", "sectionTitle": "Add Query Endpoint", @@ -1767,7 +1767,7 @@ "kind": "section" }, { - "id": "188-install#invoke-query", + "id": "189-install#invoke-query", "title": "Install Zero", "searchTitle": "Invoke Query", "sectionTitle": "Invoke Query", @@ -1777,7 +1777,7 @@ "kind": "section" }, { - "id": "189-install#more-about-queries", + "id": "190-install#more-about-queries", "title": "Install Zero", "searchTitle": "More about Queries", "sectionTitle": "More about Queries", @@ -1787,7 +1787,7 @@ "kind": "section" }, { - "id": "190-install#mutate-data", + "id": "191-install#mutate-data", "title": "Install Zero", "searchTitle": "Mutate Data", "sectionTitle": "Mutate Data", @@ -1797,7 +1797,7 @@ "kind": "section" }, { - "id": "191-install#define-mutators", + "id": "192-install#define-mutators", "title": "Install Zero", "searchTitle": "Define Mutators", "sectionTitle": "Define Mutators", @@ -1807,7 +1807,7 @@ "kind": "section" }, { - "id": "192-install#add-mutate-endpoint", + "id": "193-install#add-mutate-endpoint", "title": "Install Zero", "searchTitle": "Add Mutate Endpoint", "sectionTitle": "Add Mutate Endpoint", @@ -1817,7 +1817,7 @@ "kind": "section" }, { - "id": "193-install#invoke-mutators", + "id": "194-install#invoke-mutators", "title": "Install Zero", "searchTitle": "Invoke Mutators", "sectionTitle": "Invoke Mutators", @@ -1827,7 +1827,7 @@ "kind": "section" }, { - "id": "194-install#more-about-mutators", + "id": "195-install#more-about-mutators", "title": "Install Zero", "searchTitle": "More about Mutators", "sectionTitle": "More about Mutators", @@ -1850,7 +1850,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 wait for the server write to succeed: 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`, 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 server write – this involves a round-trip. const serverRes = await write.server if (serverRes.type === 'error') { throw new Error( `Mutator failed on server`, serverRes.error ) } // issue-123 is written to server and any results are // synced to this client. // read2 could potentially be undefined here, for example if the // server mutator rejected the write. const read2 = await zero.run( queries.issue.byId('issue-123').one() ) If the client-side mutator fails, the .server promise is also rejected with the same error. You don't have to listen to both promises, the server promise covers both cases. 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' }) 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.", "headings": [ { "text": "Architecture", @@ -1972,7 +1972,7 @@ "kind": "page" }, { - "id": "195-mutators#architecture", + "id": "196-mutators#architecture", "title": "Mutators", "searchTitle": "Architecture", "sectionTitle": "Architecture", @@ -1982,7 +1982,7 @@ "kind": "section" }, { - "id": "196-mutators#life-of-a-mutation", + "id": "197-mutators#life-of-a-mutation", "title": "Mutators", "searchTitle": "Life of a Mutation", "sectionTitle": "Life of a Mutation", @@ -1992,7 +1992,7 @@ "kind": "section" }, { - "id": "197-mutators#defining-mutators", + "id": "198-mutators#defining-mutators", "title": "Mutators", "searchTitle": "Defining Mutators", "sectionTitle": "Defining Mutators", @@ -2002,7 +2002,7 @@ "kind": "section" }, { - "id": "198-mutators#basics", + "id": "199-mutators#basics", "title": "Mutators", "searchTitle": "Basics", "sectionTitle": "Basics", @@ -2012,7 +2012,7 @@ "kind": "section" }, { - "id": "199-mutators#writing-data", + "id": "200-mutators#writing-data", "title": "Mutators", "searchTitle": "Writing Data", "sectionTitle": "Writing Data", @@ -2022,7 +2022,7 @@ "kind": "section" }, { - "id": "200-mutators#insert", + "id": "201-mutators#insert", "title": "Mutators", "searchTitle": "Insert", "sectionTitle": "Insert", @@ -2032,7 +2032,7 @@ "kind": "section" }, { - "id": "201-mutators#upsert", + "id": "202-mutators#upsert", "title": "Mutators", "searchTitle": "Upsert", "sectionTitle": "Upsert", @@ -2042,7 +2042,7 @@ "kind": "section" }, { - "id": "202-mutators#update", + "id": "203-mutators#update", "title": "Mutators", "searchTitle": "Update", "sectionTitle": "Update", @@ -2052,7 +2052,7 @@ "kind": "section" }, { - "id": "203-mutators#delete", + "id": "204-mutators#delete", "title": "Mutators", "searchTitle": "Delete", "sectionTitle": "Delete", @@ -2062,7 +2062,7 @@ "kind": "section" }, { - "id": "204-mutators#arguments", + "id": "205-mutators#arguments", "title": "Mutators", "searchTitle": "Arguments", "sectionTitle": "Arguments", @@ -2072,7 +2072,7 @@ "kind": "section" }, { - "id": "205-mutators#reading-data", + "id": "206-mutators#reading-data", "title": "Mutators", "searchTitle": "Reading Data", "sectionTitle": "Reading Data", @@ -2082,7 +2082,7 @@ "kind": "section" }, { - "id": "206-mutators#context", + "id": "207-mutators#context", "title": "Mutators", "searchTitle": "Context", "sectionTitle": "Context", @@ -2092,7 +2092,7 @@ "kind": "section" }, { - "id": "207-mutators#mutator-registries", + "id": "208-mutators#mutator-registries", "title": "Mutators", "searchTitle": "Mutator Registries", "sectionTitle": "Mutator Registries", @@ -2102,7 +2102,7 @@ "kind": "section" }, { - "id": "208-mutators#mutator-names", + "id": "209-mutators#mutator-names", "title": "Mutators", "searchTitle": "Mutator Names", "sectionTitle": "Mutator Names", @@ -2112,7 +2112,7 @@ "kind": "section" }, { - "id": "209-mutators#mutatorsts", + "id": "210-mutators#mutatorsts", "title": "Mutators", "searchTitle": "mutators.ts", "sectionTitle": "mutators.ts", @@ -2122,7 +2122,7 @@ "kind": "section" }, { - "id": "210-mutators#registration", + "id": "211-mutators#registration", "title": "Mutators", "searchTitle": "Registration", "sectionTitle": "Registration", @@ -2132,7 +2132,7 @@ "kind": "section" }, { - "id": "211-mutators#server-setup", + "id": "212-mutators#server-setup", "title": "Mutators", "searchTitle": "Server Setup", "sectionTitle": "Server Setup", @@ -2142,7 +2142,7 @@ "kind": "section" }, { - "id": "212-mutators#registering-the-endpoint", + "id": "213-mutators#registering-the-endpoint", "title": "Mutators", "searchTitle": "Registering the Endpoint", "sectionTitle": "Registering the Endpoint", @@ -2152,7 +2152,7 @@ "kind": "section" }, { - "id": "213-mutators#implementing-the-endpoint", + "id": "214-mutators#implementing-the-endpoint", "title": "Mutators", "searchTitle": "Implementing the Endpoint", "sectionTitle": "Implementing the Endpoint", @@ -2162,7 +2162,7 @@ "kind": "section" }, { - "id": "214-mutators#handling-errors", + "id": "215-mutators#handling-errors", "title": "Mutators", "searchTitle": "Handling Errors", "sectionTitle": "Handling Errors", @@ -2172,7 +2172,7 @@ "kind": "section" }, { - "id": "215-mutators#custom-mutate-url", + "id": "216-mutators#custom-mutate-url", "title": "Mutators", "searchTitle": "Custom Mutate URL", "sectionTitle": "Custom Mutate URL", @@ -2182,7 +2182,7 @@ "kind": "section" }, { - "id": "216-mutators#url-patterns", + "id": "217-mutators#url-patterns", "title": "Mutators", "searchTitle": "URL Patterns", "sectionTitle": "URL Patterns", @@ -2192,7 +2192,7 @@ "kind": "section" }, { - "id": "217-mutators#server-specific-code", + "id": "218-mutators#server-specific-code", "title": "Mutators", "searchTitle": "Server-Specific Code", "sectionTitle": "Server-Specific Code", @@ -2202,27 +2202,27 @@ "kind": "section" }, { - "id": "218-mutators#running-mutators", + "id": "219-mutators#running-mutators", "title": "Mutators", "searchTitle": "Running Mutators", "sectionTitle": "Running Mutators", "sectionId": "running-mutators", "url": "/docs/mutators", - "content": "Once you have registered your mutators, you can invoke them with zero.mutate: import {mutators} from 'mutators.ts' zero.mutate( mutators.issue.update({ id: crypto.randomUUID(), title: 'New title' }) ) Client-generated random IDs from crypto.randomUUID(), uuid, ulid, or nanoid work much better with sync engines like Zero. See IDs for more details. Waiting for Results We typically recommend that you \"fire and forget\" mutators. Optimistic mutations make sense when the common case is that a mutation succeeds. If a mutation frequently fails, then showing the user an optimistic result isn't very useful, because it will likely be wrong. That said there are cases where it is nice to know when a write succeeded on either the client or server. One example is if you need to read a row directly after writing it. Zero's local writes are very fast (almost always < 1 frame), but because Zero is backed by IndexedDB, writes are still technically asynchronous and reads directly after a write may not return the new data. You can use the .client promise in this case to wait for a write to complete on the client side: const write = zero.mutate( mutators.issue.insert({ id: crypto.randomUUID(), title: 'New title' }) ) // issue-123 not guaranteed to be present here. read1 may be undefined. const read1 = await zero.run( queries.issue.byId('issue-123').one() ) // Await client write – almost always less than 1 frame, and same // macrotask, so no browser paint will occur here. const res = await write.client if (res.type === 'error') { console.error('Mutator failed on client', res.error) } // issue-123 definitely can be read now. const read2 = await zero.run( queries.issue.byId('issue-123').one() ) You can also wait for the server write to succeed: 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`, 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 server write – this involves a round-trip. const serverRes = await write.server if (serverRes.type === 'error') { throw new Error( `Mutator failed on server`, serverRes.error ) } // issue-123 is written to server and any results are // synced to this client. // read2 could potentially be undefined here, for example if the // server mutator rejected the write. const read2 = await zero.run( queries.issue.byId('issue-123').one() ) If the client-side mutator fails, the .server promise is also rejected with the same error. You don't have to listen to both promises, the server promise covers both cases. There is not yet a way to return data from mutators in the success case. Let us know if you need this.", + "content": "Once you have registered your mutators, you can invoke them with zero.mutate: import {mutators} from 'mutators.ts' zero.mutate( mutators.issue.update({ id: crypto.randomUUID(), title: 'New title' }) ) Client-generated random IDs from crypto.randomUUID(), uuid, ulid, or nanoid work much better with sync engines like Zero. See IDs for more details. Waiting for Results We typically recommend that you \"fire and forget\" mutators. Optimistic mutations make sense when the common case is that a mutation succeeds. If a mutation frequently fails, then showing the user an optimistic result isn't very useful, because it will likely be wrong. That said there are cases where it is nice to know when a write succeeded on either the client or server. One example is if you need to read a row directly after writing it. Zero's local writes are very fast (almost always < 1 frame), but because Zero is backed by IndexedDB, writes are still technically asynchronous and reads directly after a write may not return the new data. You can use the .client promise in this case to wait for a write to complete on the client side: const write = zero.mutate( mutators.issue.insert({ id: crypto.randomUUID(), title: 'New title' }) ) // issue-123 not guaranteed to be present here. read1 may be undefined. const read1 = await zero.run( queries.issue.byId('issue-123').one() ) // Await client write – almost always less than 1 frame, and same // macrotask, so no browser paint will occur here. const res = await write.client if (res.type === 'error') { console.error('Mutator failed on client', res.error) } // issue-123 definitely can be read now. const read2 = await zero.run( queries.issue.byId('issue-123').one() ) You can also await .server for the server result: const write = zero.mutate( mutators.issue.insert({ id: crypto.randomUUID(), title: 'New title' }) ) const clientRes = await write.client if (clientRes.type === 'error') { throw new Error(`Mutator failed on client`, { cause: clientRes.error }) } // optimistic write guaranteed to be present here, but not // server write. const read1 = await zero.run( queries.issue.byId('issue-123').one() ) // Await the server result/acknowledgment. This requires a round trip. const serverRes = await write.server if (serverRes.type === 'error') { throw new Error(`Mutator failed on server`, { cause: serverRes.error }) } // The server acknowledged the mutation, but its Postgres changes // may not have replicated to this client yet. This read can still // reflect optimistic rather than authoritative state. const read2 = await zero.run( queries.issue.byId('issue-123').one() ) If the client-side mutator fails, .server also resolves to an error result. Awaiting .server therefore covers both client- and server-side failures. There is not yet a way to return data from mutators in the success case. Let us know if you need this.", "kind": "section" }, { - "id": "219-mutators#waiting-for-results", + "id": "220-mutators#waiting-for-results", "title": "Mutators", "searchTitle": "Waiting for Results", "sectionTitle": "Waiting for Results", "sectionId": "waiting-for-results", "url": "/docs/mutators", - "content": "We typically recommend that you \"fire and forget\" mutators. Optimistic mutations make sense when the common case is that a mutation succeeds. If a mutation frequently fails, then showing the user an optimistic result isn't very useful, because it will likely be wrong. That said there are cases where it is nice to know when a write succeeded on either the client or server. One example is if you need to read a row directly after writing it. Zero's local writes are very fast (almost always < 1 frame), but because Zero is backed by IndexedDB, writes are still technically asynchronous and reads directly after a write may not return the new data. You can use the .client promise in this case to wait for a write to complete on the client side: const write = zero.mutate( mutators.issue.insert({ id: crypto.randomUUID(), title: 'New title' }) ) // issue-123 not guaranteed to be present here. read1 may be undefined. const read1 = await zero.run( queries.issue.byId('issue-123').one() ) // Await client write – almost always less than 1 frame, and same // macrotask, so no browser paint will occur here. const res = await write.client if (res.type === 'error') { console.error('Mutator failed on client', res.error) } // issue-123 definitely can be read now. const read2 = await zero.run( queries.issue.byId('issue-123').one() ) You can also wait for the server write to succeed: 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`, 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 server write – this involves a round-trip. const serverRes = await write.server if (serverRes.type === 'error') { throw new Error( `Mutator failed on server`, serverRes.error ) } // issue-123 is written to server and any results are // synced to this client. // read2 could potentially be undefined here, for example if the // server mutator rejected the write. const read2 = await zero.run( queries.issue.byId('issue-123').one() ) If the client-side mutator fails, the .server promise is also rejected with the same error. You don't have to listen to both promises, the server promise covers both cases. There is not yet a way to return data from mutators in the success case. Let us know if you need this.", + "content": "We typically recommend that you \"fire and forget\" mutators. Optimistic mutations make sense when the common case is that a mutation succeeds. If a mutation frequently fails, then showing the user an optimistic result isn't very useful, because it will likely be wrong. That said there are cases where it is nice to know when a write succeeded on either the client or server. One example is if you need to read a row directly after writing it. Zero's local writes are very fast (almost always < 1 frame), but because Zero is backed by IndexedDB, writes are still technically asynchronous and reads directly after a write may not return the new data. You can use the .client promise in this case to wait for a write to complete on the client side: const write = zero.mutate( mutators.issue.insert({ id: crypto.randomUUID(), title: 'New title' }) ) // issue-123 not guaranteed to be present here. read1 may be undefined. const read1 = await zero.run( queries.issue.byId('issue-123').one() ) // Await client write – almost always less than 1 frame, and same // macrotask, so no browser paint will occur here. const res = await write.client if (res.type === 'error') { console.error('Mutator failed on client', res.error) } // issue-123 definitely can be read now. const read2 = await zero.run( queries.issue.byId('issue-123').one() ) You can also await .server for the server result: const write = zero.mutate( mutators.issue.insert({ id: crypto.randomUUID(), title: 'New title' }) ) const clientRes = await write.client if (clientRes.type === 'error') { throw new Error(`Mutator failed on client`, { cause: clientRes.error }) } // optimistic write guaranteed to be present here, but not // server write. const read1 = await zero.run( queries.issue.byId('issue-123').one() ) // Await the server result/acknowledgment. This requires a round trip. const serverRes = await write.server if (serverRes.type === 'error') { throw new Error(`Mutator failed on server`, { cause: serverRes.error }) } // The server acknowledged the mutation, but its Postgres changes // may not have replicated to this client yet. This read can still // reflect optimistic rather than authoritative state. const read2 = await zero.run( queries.issue.byId('issue-123').one() ) If the client-side mutator fails, .server also resolves to an error result. Awaiting .server therefore covers both client- and server-side failures. There is not yet a way to return data from mutators in the success case. Let us know if you need this.", "kind": "section" }, { - "id": "220-mutators#permissions", + "id": "221-mutators#permissions", "title": "Mutators", "searchTitle": "Permissions", "sectionTitle": "Permissions", @@ -2232,7 +2232,7 @@ "kind": "section" }, { - "id": "221-mutators#dropping-down-to-raw-sql", + "id": "222-mutators#dropping-down-to-raw-sql", "title": "Mutators", "searchTitle": "Dropping Down to Raw SQL", "sectionTitle": "Dropping Down to Raw SQL", @@ -2242,7 +2242,7 @@ "kind": "section" }, { - "id": "222-mutators#notifications-and-async-work", + "id": "223-mutators#notifications-and-async-work", "title": "Mutators", "searchTitle": "Notifications and Async Work", "sectionTitle": "Notifications and Async Work", @@ -2252,7 +2252,7 @@ "kind": "section" }, { - "id": "223-mutators#custom-mutate-implementation", + "id": "224-mutators#custom-mutate-implementation", "title": "Mutators", "searchTitle": "Custom Mutate Implementation", "sectionTitle": "Custom Mutate Implementation", @@ -2276,7 +2276,7 @@ "kind": "page" }, { - "id": "224-open-source#business-model", + "id": "225-open-source#business-model", "title": "Zero is Open Source Software", "searchTitle": "Business Model", "sectionTitle": "Business Model", @@ -2290,7 +2290,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 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 zero.sync zero.mutation", "headings": [ { "text": "Grafana Cloud Walkthrough", @@ -2328,7 +2328,7 @@ "kind": "page" }, { - "id": "225-otel#grafana-cloud-walkthrough", + "id": "226-otel#grafana-cloud-walkthrough", "title": "OpenTelemetry", "searchTitle": "Grafana Cloud Walkthrough", "sectionTitle": "Grafana Cloud Walkthrough", @@ -2338,7 +2338,7 @@ "kind": "section" }, { - "id": "226-otel#distributed-tracing", + "id": "227-otel#distributed-tracing", "title": "OpenTelemetry", "searchTitle": "Distributed Tracing", "sectionTitle": "Distributed Tracing", @@ -2348,17 +2348,17 @@ "kind": "section" }, { - "id": "227-otel#metrics-reference", + "id": "228-otel#metrics-reference", "title": "OpenTelemetry", "searchTitle": "Metrics Reference", "sectionTitle": "Metrics Reference", "sectionId": "metrics-reference", "url": "/docs/otel", - "content": "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 zero.sync zero.mutation", "kind": "section" }, { - "id": "228-otel#zeroserver", + "id": "229-otel#zeroserver", "title": "OpenTelemetry", "searchTitle": "zero.server", "sectionTitle": "zero.server", @@ -2368,7 +2368,7 @@ "kind": "section" }, { - "id": "229-otel#zeroreplica", + "id": "230-otel#zeroreplica", "title": "OpenTelemetry", "searchTitle": "zero.replica", "sectionTitle": "zero.replica", @@ -2378,7 +2378,7 @@ "kind": "section" }, { - "id": "230-otel#zeroreplication", + "id": "231-otel#zeroreplication", "title": "OpenTelemetry", "searchTitle": "zero.replication", "sectionTitle": "zero.replication", @@ -2388,7 +2388,7 @@ "kind": "section" }, { - "id": "231-otel#zerosync", + "id": "232-otel#zerosync", "title": "OpenTelemetry", "searchTitle": "zero.sync", "sectionTitle": "zero.sync", @@ -2398,7 +2398,7 @@ "kind": "section" }, { - "id": "232-otel#zeromutation", + "id": "233-otel#zeromutation", "title": "OpenTelemetry", "searchTitle": "zero.mutation", "sectionTitle": "zero.mutation", @@ -2458,7 +2458,7 @@ "kind": "page" }, { - "id": "233-postgres-support#object-names", + "id": "234-postgres-support#object-names", "title": "Supported Postgres Features", "searchTitle": "Object Names", "sectionTitle": "Object Names", @@ -2468,7 +2468,7 @@ "kind": "section" }, { - "id": "234-postgres-support#object-types", + "id": "235-postgres-support#object-types", "title": "Supported Postgres Features", "searchTitle": "Object Types", "sectionTitle": "Object Types", @@ -2478,7 +2478,7 @@ "kind": "section" }, { - "id": "235-postgres-support#column-types", + "id": "236-postgres-support#column-types", "title": "Supported Postgres Features", "searchTitle": "Column Types", "sectionTitle": "Column Types", @@ -2488,7 +2488,7 @@ "kind": "section" }, { - "id": "236-postgres-support#column-defaults", + "id": "237-postgres-support#column-defaults", "title": "Supported Postgres Features", "searchTitle": "Column Defaults", "sectionTitle": "Column Defaults", @@ -2498,7 +2498,7 @@ "kind": "section" }, { - "id": "237-postgres-support#ids", + "id": "238-postgres-support#ids", "title": "Supported Postgres Features", "searchTitle": "IDs", "sectionTitle": "IDs", @@ -2508,7 +2508,7 @@ "kind": "section" }, { - "id": "238-postgres-support#primary-keys", + "id": "239-postgres-support#primary-keys", "title": "Supported Postgres Features", "searchTitle": "Primary Keys", "sectionTitle": "Primary Keys", @@ -2518,7 +2518,7 @@ "kind": "section" }, { - "id": "239-postgres-support#limiting-replication", + "id": "240-postgres-support#limiting-replication", "title": "Supported Postgres Features", "searchTitle": "Limiting Replication", "sectionTitle": "Limiting Replication", @@ -2528,7 +2528,7 @@ "kind": "section" }, { - "id": "240-postgres-support#zero-cache-replication", + "id": "241-postgres-support#zero-cache-replication", "title": "Supported Postgres Features", "searchTitle": "zero-cache replication", "sectionTitle": "zero-cache replication", @@ -2538,7 +2538,7 @@ "kind": "section" }, { - "id": "241-postgres-support#browser-client-replication", + "id": "242-postgres-support#browser-client-replication", "title": "Supported Postgres Features", "searchTitle": "Browser client replication", "sectionTitle": "Browser client replication", @@ -2548,7 +2548,7 @@ "kind": "section" }, { - "id": "242-postgres-support#schema-changes", + "id": "243-postgres-support#schema-changes", "title": "Supported Postgres Features", "searchTitle": "Schema changes", "sectionTitle": "Schema changes", @@ -2584,7 +2584,7 @@ "kind": "page" }, { - "id": "243-previews#overview", + "id": "244-previews#overview", "title": "Previews", "searchTitle": "Overview", "sectionTitle": "Overview", @@ -2594,7 +2594,7 @@ "kind": "section" }, { - "id": "244-previews#configure-allowed-endpoint-patterns", + "id": "245-previews#configure-allowed-endpoint-patterns", "title": "Previews", "searchTitle": "Configure Allowed Endpoint Patterns", "sectionTitle": "Configure Allowed Endpoint Patterns", @@ -2604,7 +2604,7 @@ "kind": "section" }, { - "id": "245-previews#choose-endpoint-urls-in-the-client", + "id": "246-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 +2614,7 @@ "kind": "section" }, { - "id": "246-previews#schema-changes-in-previews", + "id": "247-previews#schema-changes-in-previews", "title": "Previews", "searchTitle": "Schema Changes in Previews", "sectionTitle": "Schema Changes in Previews", @@ -2758,7 +2758,7 @@ "kind": "page" }, { - "id": "247-queries#architecture", + "id": "248-queries#architecture", "title": "Queries", "searchTitle": "Architecture", "sectionTitle": "Architecture", @@ -2768,7 +2768,7 @@ "kind": "section" }, { - "id": "248-queries#life-of-a-query", + "id": "249-queries#life-of-a-query", "title": "Queries", "searchTitle": "Life of a Query", "sectionTitle": "Life of a Query", @@ -2778,7 +2778,7 @@ "kind": "section" }, { - "id": "249-queries#defining-queries", + "id": "250-queries#defining-queries", "title": "Queries", "searchTitle": "Defining Queries", "sectionTitle": "Defining Queries", @@ -2788,7 +2788,7 @@ "kind": "section" }, { - "id": "250-queries#basics", + "id": "251-queries#basics", "title": "Queries", "searchTitle": "Basics", "sectionTitle": "Basics", @@ -2798,7 +2798,7 @@ "kind": "section" }, { - "id": "251-queries#arguments", + "id": "252-queries#arguments", "title": "Queries", "searchTitle": "Arguments", "sectionTitle": "Arguments", @@ -2808,7 +2808,7 @@ "kind": "section" }, { - "id": "252-queries#query-registries", + "id": "253-queries#query-registries", "title": "Queries", "searchTitle": "Query Registries", "sectionTitle": "Query Registries", @@ -2818,7 +2818,7 @@ "kind": "section" }, { - "id": "253-queries#query-names", + "id": "254-queries#query-names", "title": "Queries", "searchTitle": "Query Names", "sectionTitle": "Query Names", @@ -2828,7 +2828,7 @@ "kind": "section" }, { - "id": "254-queries#context", + "id": "255-queries#context", "title": "Queries", "searchTitle": "Context", "sectionTitle": "Context", @@ -2838,7 +2838,7 @@ "kind": "section" }, { - "id": "255-queries#queriests", + "id": "256-queries#queriests", "title": "Queries", "searchTitle": "queries.ts", "sectionTitle": "queries.ts", @@ -2848,7 +2848,7 @@ "kind": "section" }, { - "id": "256-queries#server-setup", + "id": "257-queries#server-setup", "title": "Queries", "searchTitle": "Server Setup", "sectionTitle": "Server Setup", @@ -2858,7 +2858,7 @@ "kind": "section" }, { - "id": "257-queries#registering-the-endpoint", + "id": "258-queries#registering-the-endpoint", "title": "Queries", "searchTitle": "Registering the Endpoint", "sectionTitle": "Registering the Endpoint", @@ -2868,7 +2868,7 @@ "kind": "section" }, { - "id": "258-queries#implementing-the-endpoint", + "id": "259-queries#implementing-the-endpoint", "title": "Queries", "searchTitle": "Implementing the Endpoint", "sectionTitle": "Implementing the Endpoint", @@ -2878,7 +2878,7 @@ "kind": "section" }, { - "id": "259-queries#custom-query-url", + "id": "260-queries#custom-query-url", "title": "Queries", "searchTitle": "Custom Query URL", "sectionTitle": "Custom Query URL", @@ -2888,7 +2888,7 @@ "kind": "section" }, { - "id": "260-queries#url-patterns", + "id": "261-queries#url-patterns", "title": "Queries", "searchTitle": "URL Patterns", "sectionTitle": "URL Patterns", @@ -2898,7 +2898,7 @@ "kind": "section" }, { - "id": "261-queries#running-queries", + "id": "262-queries#running-queries", "title": "Queries", "searchTitle": "Running Queries", "sectionTitle": "Running Queries", @@ -2908,7 +2908,7 @@ "kind": "section" }, { - "id": "262-queries#reactively", + "id": "263-queries#reactively", "title": "Queries", "searchTitle": "Reactively", "sectionTitle": "Reactively", @@ -2918,7 +2918,7 @@ "kind": "section" }, { - "id": "263-queries#conditionally", + "id": "264-queries#conditionally", "title": "Queries", "searchTitle": "Conditionally", "sectionTitle": "Conditionally", @@ -2928,7 +2928,7 @@ "kind": "section" }, { - "id": "264-queries#once", + "id": "265-queries#once", "title": "Queries", "searchTitle": "Once", "sectionTitle": "Once", @@ -2938,7 +2938,7 @@ "kind": "section" }, { - "id": "265-queries#for-preloading", + "id": "266-queries#for-preloading", "title": "Queries", "searchTitle": "For Preloading", "sectionTitle": "For Preloading", @@ -2948,7 +2948,7 @@ "kind": "section" }, { - "id": "266-queries#missing-data", + "id": "267-queries#missing-data", "title": "Queries", "searchTitle": "Missing Data", "sectionTitle": "Missing Data", @@ -2958,7 +2958,7 @@ "kind": "section" }, { - "id": "267-queries#partial-data", + "id": "268-queries#partial-data", "title": "Queries", "searchTitle": "Partial Data", "sectionTitle": "Partial Data", @@ -2968,7 +2968,7 @@ "kind": "section" }, { - "id": "268-queries#handling-errors", + "id": "269-queries#handling-errors", "title": "Queries", "searchTitle": "Handling Errors", "sectionTitle": "Handling Errors", @@ -2978,7 +2978,7 @@ "kind": "section" }, { - "id": "269-queries#granular-updates", + "id": "270-queries#granular-updates", "title": "Queries", "searchTitle": "Granular Updates", "sectionTitle": "Granular Updates", @@ -2988,7 +2988,7 @@ "kind": "section" }, { - "id": "270-queries#query-caching", + "id": "271-queries#query-caching", "title": "Queries", "searchTitle": "Query Caching", "sectionTitle": "Query Caching", @@ -2998,7 +2998,7 @@ "kind": "section" }, { - "id": "271-queries#ttls", + "id": "272-queries#ttls", "title": "Queries", "searchTitle": "TTLs", "sectionTitle": "TTLs", @@ -3008,7 +3008,7 @@ "kind": "section" }, { - "id": "272-queries#ttl-defaults", + "id": "273-queries#ttl-defaults", "title": "Queries", "searchTitle": "TTL Defaults", "sectionTitle": "TTL Defaults", @@ -3018,7 +3018,7 @@ "kind": "section" }, { - "id": "273-queries#setting-different-ttls", + "id": "274-queries#setting-different-ttls", "title": "Queries", "searchTitle": "Setting Different TTLs", "sectionTitle": "Setting Different TTLs", @@ -3028,7 +3028,7 @@ "kind": "section" }, { - "id": "274-queries#why-zero-ttls-are-short", + "id": "275-queries#why-zero-ttls-are-short", "title": "Queries", "searchTitle": "Why Zero TTLs are Short", "sectionTitle": "Why Zero TTLs are Short", @@ -3038,7 +3038,7 @@ "kind": "section" }, { - "id": "275-queries#local-only-queries", + "id": "276-queries#local-only-queries", "title": "Queries", "searchTitle": "Local-Only Queries", "sectionTitle": "Local-Only Queries", @@ -3048,7 +3048,7 @@ "kind": "section" }, { - "id": "276-queries#custom-server-implementation", + "id": "277-queries#custom-server-implementation", "title": "Queries", "searchTitle": "Custom Server Implementation", "sectionTitle": "Custom Server Implementation", @@ -3058,7 +3058,7 @@ "kind": "section" }, { - "id": "277-queries#consistency", + "id": "278-queries#consistency", "title": "Queries", "searchTitle": "Consistency", "sectionTitle": "Consistency", @@ -3090,7 +3090,7 @@ "kind": "page" }, { - "id": "278-quickstart#hello-zero-solid", + "id": "279-quickstart#hello-zero-solid", "title": "Quickstart", "searchTitle": "hello-zero-solid", "sectionTitle": "hello-zero-solid", @@ -3100,7 +3100,7 @@ "kind": "section" }, { - "id": "279-quickstart#hello-zero-cf", + "id": "280-quickstart#hello-zero-cf", "title": "Quickstart", "searchTitle": "hello-zero-cf", "sectionTitle": "hello-zero-cf", @@ -3110,7 +3110,7 @@ "kind": "section" }, { - "id": "280-quickstart#hello-zero", + "id": "281-quickstart#hello-zero", "title": "Quickstart", "searchTitle": "hello-zero", "sectionTitle": "hello-zero", @@ -3155,7 +3155,7 @@ "kind": "page" }, { - "id": "281-react#setup", + "id": "282-react#setup", "title": "React", "searchTitle": "Setup", "sectionTitle": "Setup", @@ -3165,7 +3165,7 @@ "kind": "section" }, { - "id": "282-react#usage", + "id": "283-react#usage", "title": "React", "searchTitle": "Usage", "sectionTitle": "Usage", @@ -3175,7 +3175,7 @@ "kind": "section" }, { - "id": "283-react#suspense", + "id": "284-react#suspense", "title": "React", "searchTitle": "Suspense", "sectionTitle": "Suspense", @@ -3185,7 +3185,7 @@ "kind": "section" }, { - "id": "284-react#examples", + "id": "285-react#examples", "title": "React", "searchTitle": "Examples", "sectionTitle": "Examples", @@ -3217,7 +3217,7 @@ "kind": "page" }, { - "id": "285-release-notes/0.1#breaking-changes", + "id": "286-release-notes/0.1#breaking-changes", "title": "Zero 0.1", "searchTitle": "Breaking changes", "sectionTitle": "Breaking changes", @@ -3227,7 +3227,7 @@ "kind": "section" }, { - "id": "286-release-notes/0.1#features", + "id": "287-release-notes/0.1#features", "title": "Zero 0.1", "searchTitle": "Features", "sectionTitle": "Features", @@ -3237,7 +3237,7 @@ "kind": "section" }, { - "id": "287-release-notes/0.1#source-tree-fixes", + "id": "288-release-notes/0.1#source-tree-fixes", "title": "Zero 0.1", "searchTitle": "Source tree fixes", "sectionTitle": "Source tree fixes", @@ -3273,7 +3273,7 @@ "kind": "page" }, { - "id": "288-release-notes/0.10#install", + "id": "289-release-notes/0.10#install", "title": "Zero 0.10", "searchTitle": "Install", "sectionTitle": "Install", @@ -3283,7 +3283,7 @@ "kind": "section" }, { - "id": "289-release-notes/0.10#features", + "id": "290-release-notes/0.10#features", "title": "Zero 0.10", "searchTitle": "Features", "sectionTitle": "Features", @@ -3293,7 +3293,7 @@ "kind": "section" }, { - "id": "290-release-notes/0.10#fixes", + "id": "291-release-notes/0.10#fixes", "title": "Zero 0.10", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -3303,7 +3303,7 @@ "kind": "section" }, { - "id": "291-release-notes/0.10#breaking-changes", + "id": "292-release-notes/0.10#breaking-changes", "title": "Zero 0.10", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -3339,7 +3339,7 @@ "kind": "page" }, { - "id": "292-release-notes/0.11#install", + "id": "293-release-notes/0.11#install", "title": "Zero 0.11", "searchTitle": "Install", "sectionTitle": "Install", @@ -3349,7 +3349,7 @@ "kind": "section" }, { - "id": "293-release-notes/0.11#features", + "id": "294-release-notes/0.11#features", "title": "Zero 0.11", "searchTitle": "Features", "sectionTitle": "Features", @@ -3359,7 +3359,7 @@ "kind": "section" }, { - "id": "294-release-notes/0.11#fixes", + "id": "295-release-notes/0.11#fixes", "title": "Zero 0.11", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -3369,7 +3369,7 @@ "kind": "section" }, { - "id": "295-release-notes/0.11#breaking-changes", + "id": "296-release-notes/0.11#breaking-changes", "title": "Zero 0.11", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -3405,7 +3405,7 @@ "kind": "page" }, { - "id": "296-release-notes/0.12#install", + "id": "297-release-notes/0.12#install", "title": "Zero 0.12", "searchTitle": "Install", "sectionTitle": "Install", @@ -3415,7 +3415,7 @@ "kind": "section" }, { - "id": "297-release-notes/0.12#features", + "id": "298-release-notes/0.12#features", "title": "Zero 0.12", "searchTitle": "Features", "sectionTitle": "Features", @@ -3425,7 +3425,7 @@ "kind": "section" }, { - "id": "298-release-notes/0.12#fixes", + "id": "299-release-notes/0.12#fixes", "title": "Zero 0.12", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -3435,7 +3435,7 @@ "kind": "section" }, { - "id": "299-release-notes/0.12#breaking-changes", + "id": "300-release-notes/0.12#breaking-changes", "title": "Zero 0.12", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -3471,7 +3471,7 @@ "kind": "page" }, { - "id": "300-release-notes/0.13#install", + "id": "301-release-notes/0.13#install", "title": "Zero 0.13", "searchTitle": "Install", "sectionTitle": "Install", @@ -3481,7 +3481,7 @@ "kind": "section" }, { - "id": "301-release-notes/0.13#features", + "id": "302-release-notes/0.13#features", "title": "Zero 0.13", "searchTitle": "Features", "sectionTitle": "Features", @@ -3491,7 +3491,7 @@ "kind": "section" }, { - "id": "302-release-notes/0.13#fixes", + "id": "303-release-notes/0.13#fixes", "title": "Zero 0.13", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -3501,7 +3501,7 @@ "kind": "section" }, { - "id": "303-release-notes/0.13#breaking-changes", + "id": "304-release-notes/0.13#breaking-changes", "title": "Zero 0.13", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -3537,7 +3537,7 @@ "kind": "page" }, { - "id": "304-release-notes/0.14#install", + "id": "305-release-notes/0.14#install", "title": "Zero 0.14", "searchTitle": "Install", "sectionTitle": "Install", @@ -3547,7 +3547,7 @@ "kind": "section" }, { - "id": "305-release-notes/0.14#features", + "id": "306-release-notes/0.14#features", "title": "Zero 0.14", "searchTitle": "Features", "sectionTitle": "Features", @@ -3557,7 +3557,7 @@ "kind": "section" }, { - "id": "306-release-notes/0.14#fixes", + "id": "307-release-notes/0.14#fixes", "title": "Zero 0.14", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -3567,7 +3567,7 @@ "kind": "section" }, { - "id": "307-release-notes/0.14#breaking-changes", + "id": "308-release-notes/0.14#breaking-changes", "title": "Zero 0.14", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -3607,7 +3607,7 @@ "kind": "page" }, { - "id": "308-release-notes/0.15#install", + "id": "309-release-notes/0.15#install", "title": "Zero 0.15", "searchTitle": "Install", "sectionTitle": "Install", @@ -3617,7 +3617,7 @@ "kind": "section" }, { - "id": "309-release-notes/0.15#upgrade-guide", + "id": "310-release-notes/0.15#upgrade-guide", "title": "Zero 0.15", "searchTitle": "Upgrade Guide", "sectionTitle": "Upgrade Guide", @@ -3627,7 +3627,7 @@ "kind": "section" }, { - "id": "310-release-notes/0.15#features", + "id": "311-release-notes/0.15#features", "title": "Zero 0.15", "searchTitle": "Features", "sectionTitle": "Features", @@ -3637,7 +3637,7 @@ "kind": "section" }, { - "id": "311-release-notes/0.15#fixes", + "id": "312-release-notes/0.15#fixes", "title": "Zero 0.15", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -3647,7 +3647,7 @@ "kind": "section" }, { - "id": "312-release-notes/0.15#breaking-changes", + "id": "313-release-notes/0.15#breaking-changes", "title": "Zero 0.15", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -3687,7 +3687,7 @@ "kind": "page" }, { - "id": "313-release-notes/0.16#install", + "id": "314-release-notes/0.16#install", "title": "Zero 0.16", "searchTitle": "Install", "sectionTitle": "Install", @@ -3697,7 +3697,7 @@ "kind": "section" }, { - "id": "314-release-notes/0.16#upgrading", + "id": "315-release-notes/0.16#upgrading", "title": "Zero 0.16", "searchTitle": "Upgrading", "sectionTitle": "Upgrading", @@ -3707,7 +3707,7 @@ "kind": "section" }, { - "id": "315-release-notes/0.16#features", + "id": "316-release-notes/0.16#features", "title": "Zero 0.16", "searchTitle": "Features", "sectionTitle": "Features", @@ -3717,7 +3717,7 @@ "kind": "section" }, { - "id": "316-release-notes/0.16#fixes", + "id": "317-release-notes/0.16#fixes", "title": "Zero 0.16", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -3727,7 +3727,7 @@ "kind": "section" }, { - "id": "317-release-notes/0.16#breaking-changes", + "id": "318-release-notes/0.16#breaking-changes", "title": "Zero 0.16", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -3767,7 +3767,7 @@ "kind": "page" }, { - "id": "318-release-notes/0.17#install", + "id": "319-release-notes/0.17#install", "title": "Zero 0.17", "searchTitle": "Install", "sectionTitle": "Install", @@ -3777,7 +3777,7 @@ "kind": "section" }, { - "id": "319-release-notes/0.17#upgrading", + "id": "320-release-notes/0.17#upgrading", "title": "Zero 0.17", "searchTitle": "Upgrading", "sectionTitle": "Upgrading", @@ -3787,7 +3787,7 @@ "kind": "section" }, { - "id": "320-release-notes/0.17#features", + "id": "321-release-notes/0.17#features", "title": "Zero 0.17", "searchTitle": "Features", "sectionTitle": "Features", @@ -3797,7 +3797,7 @@ "kind": "section" }, { - "id": "321-release-notes/0.17#fixes", + "id": "322-release-notes/0.17#fixes", "title": "Zero 0.17", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -3807,7 +3807,7 @@ "kind": "section" }, { - "id": "322-release-notes/0.17#breaking-changes", + "id": "323-release-notes/0.17#breaking-changes", "title": "Zero 0.17", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -3847,7 +3847,7 @@ "kind": "page" }, { - "id": "323-release-notes/0.18#install", + "id": "324-release-notes/0.18#install", "title": "Zero 0.18", "searchTitle": "Install", "sectionTitle": "Install", @@ -3857,7 +3857,7 @@ "kind": "section" }, { - "id": "324-release-notes/0.18#upgrading", + "id": "325-release-notes/0.18#upgrading", "title": "Zero 0.18", "searchTitle": "Upgrading", "sectionTitle": "Upgrading", @@ -3867,7 +3867,7 @@ "kind": "section" }, { - "id": "325-release-notes/0.18#features", + "id": "326-release-notes/0.18#features", "title": "Zero 0.18", "searchTitle": "Features", "sectionTitle": "Features", @@ -3877,7 +3877,7 @@ "kind": "section" }, { - "id": "326-release-notes/0.18#fixes", + "id": "327-release-notes/0.18#fixes", "title": "Zero 0.18", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -3887,7 +3887,7 @@ "kind": "section" }, { - "id": "327-release-notes/0.18#breaking-changes", + "id": "328-release-notes/0.18#breaking-changes", "title": "Zero 0.18", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -3927,7 +3927,7 @@ "kind": "page" }, { - "id": "328-release-notes/0.19#install", + "id": "329-release-notes/0.19#install", "title": "Zero 0.19", "searchTitle": "Install", "sectionTitle": "Install", @@ -3937,7 +3937,7 @@ "kind": "section" }, { - "id": "329-release-notes/0.19#upgrading", + "id": "330-release-notes/0.19#upgrading", "title": "Zero 0.19", "searchTitle": "Upgrading", "sectionTitle": "Upgrading", @@ -3947,7 +3947,7 @@ "kind": "section" }, { - "id": "330-release-notes/0.19#features", + "id": "331-release-notes/0.19#features", "title": "Zero 0.19", "searchTitle": "Features", "sectionTitle": "Features", @@ -3957,7 +3957,7 @@ "kind": "section" }, { - "id": "331-release-notes/0.19#fixes", + "id": "332-release-notes/0.19#fixes", "title": "Zero 0.19", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -3967,7 +3967,7 @@ "kind": "section" }, { - "id": "332-release-notes/0.19#breaking-changes", + "id": "333-release-notes/0.19#breaking-changes", "title": "Zero 0.19", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -4011,7 +4011,7 @@ "kind": "page" }, { - "id": "333-release-notes/0.2#breaking-changes", + "id": "334-release-notes/0.2#breaking-changes", "title": "Zero 0.2", "searchTitle": "Breaking changes", "sectionTitle": "Breaking changes", @@ -4021,7 +4021,7 @@ "kind": "section" }, { - "id": "334-release-notes/0.2#features", + "id": "335-release-notes/0.2#features", "title": "Zero 0.2", "searchTitle": "Features", "sectionTitle": "Features", @@ -4031,7 +4031,7 @@ "kind": "section" }, { - "id": "335-release-notes/0.2#fixes", + "id": "336-release-notes/0.2#fixes", "title": "Zero 0.2", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -4041,7 +4041,7 @@ "kind": "section" }, { - "id": "336-release-notes/0.2#docs", + "id": "337-release-notes/0.2#docs", "title": "Zero 0.2", "searchTitle": "Docs", "sectionTitle": "Docs", @@ -4051,7 +4051,7 @@ "kind": "section" }, { - "id": "337-release-notes/0.2#source-tree-fixes", + "id": "338-release-notes/0.2#source-tree-fixes", "title": "Zero 0.2", "searchTitle": "Source tree fixes", "sectionTitle": "Source tree fixes", @@ -4061,7 +4061,7 @@ "kind": "section" }, { - "id": "338-release-notes/0.2#zbugs", + "id": "339-release-notes/0.2#zbugs", "title": "Zero 0.2", "searchTitle": "zbugs", "sectionTitle": "zbugs", @@ -4101,7 +4101,7 @@ "kind": "page" }, { - "id": "339-release-notes/0.20#install", + "id": "340-release-notes/0.20#install", "title": "Zero 0.20", "searchTitle": "Install", "sectionTitle": "Install", @@ -4111,7 +4111,7 @@ "kind": "section" }, { - "id": "340-release-notes/0.20#upgrading", + "id": "341-release-notes/0.20#upgrading", "title": "Zero 0.20", "searchTitle": "Upgrading", "sectionTitle": "Upgrading", @@ -4121,7 +4121,7 @@ "kind": "section" }, { - "id": "341-release-notes/0.20#features", + "id": "342-release-notes/0.20#features", "title": "Zero 0.20", "searchTitle": "Features", "sectionTitle": "Features", @@ -4131,7 +4131,7 @@ "kind": "section" }, { - "id": "342-release-notes/0.20#fixes", + "id": "343-release-notes/0.20#fixes", "title": "Zero 0.20", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -4141,7 +4141,7 @@ "kind": "section" }, { - "id": "343-release-notes/0.20#breaking-changes", + "id": "344-release-notes/0.20#breaking-changes", "title": "Zero 0.20", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -4181,7 +4181,7 @@ "kind": "page" }, { - "id": "344-release-notes/0.21#install", + "id": "345-release-notes/0.21#install", "title": "Zero 0.21", "searchTitle": "Install", "sectionTitle": "Install", @@ -4191,7 +4191,7 @@ "kind": "section" }, { - "id": "345-release-notes/0.21#upgrading", + "id": "346-release-notes/0.21#upgrading", "title": "Zero 0.21", "searchTitle": "Upgrading", "sectionTitle": "Upgrading", @@ -4201,7 +4201,7 @@ "kind": "section" }, { - "id": "346-release-notes/0.21#features", + "id": "347-release-notes/0.21#features", "title": "Zero 0.21", "searchTitle": "Features", "sectionTitle": "Features", @@ -4211,7 +4211,7 @@ "kind": "section" }, { - "id": "347-release-notes/0.21#fixes", + "id": "348-release-notes/0.21#fixes", "title": "Zero 0.21", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -4221,7 +4221,7 @@ "kind": "section" }, { - "id": "348-release-notes/0.21#breaking-changes", + "id": "349-release-notes/0.21#breaking-changes", "title": "Zero 0.21", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -4273,7 +4273,7 @@ "kind": "page" }, { - "id": "349-release-notes/0.22#install", + "id": "350-release-notes/0.22#install", "title": "Zero 0.22", "searchTitle": "Install", "sectionTitle": "Install", @@ -4283,7 +4283,7 @@ "kind": "section" }, { - "id": "350-release-notes/0.22#upgrading", + "id": "351-release-notes/0.22#upgrading", "title": "Zero 0.22", "searchTitle": "Upgrading", "sectionTitle": "Upgrading", @@ -4293,7 +4293,7 @@ "kind": "section" }, { - "id": "351-release-notes/0.22#how-ttls-used-to-work", + "id": "352-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 +4303,7 @@ "kind": "section" }, { - "id": "352-release-notes/0.22#how-ttls-work-now", + "id": "353-release-notes/0.22#how-ttls-work-now", "title": "Zero 0.22", "searchTitle": "How TTLs Work Now", "sectionTitle": "How TTLs Work Now", @@ -4313,7 +4313,7 @@ "kind": "section" }, { - "id": "353-release-notes/0.22#using-new-ttls", + "id": "354-release-notes/0.22#using-new-ttls", "title": "Zero 0.22", "searchTitle": "Using New TTLs", "sectionTitle": "Using New TTLs", @@ -4323,7 +4323,7 @@ "kind": "section" }, { - "id": "354-release-notes/0.22#features", + "id": "355-release-notes/0.22#features", "title": "Zero 0.22", "searchTitle": "Features", "sectionTitle": "Features", @@ -4333,7 +4333,7 @@ "kind": "section" }, { - "id": "355-release-notes/0.22#fixes", + "id": "356-release-notes/0.22#fixes", "title": "Zero 0.22", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -4343,7 +4343,7 @@ "kind": "section" }, { - "id": "356-release-notes/0.22#breaking-changes", + "id": "357-release-notes/0.22#breaking-changes", "title": "Zero 0.22", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -4387,7 +4387,7 @@ "kind": "page" }, { - "id": "357-release-notes/0.23#install", + "id": "358-release-notes/0.23#install", "title": "Zero 0.23", "searchTitle": "Install", "sectionTitle": "Install", @@ -4397,7 +4397,7 @@ "kind": "section" }, { - "id": "358-release-notes/0.23#upgrading", + "id": "359-release-notes/0.23#upgrading", "title": "Zero 0.23", "searchTitle": "Upgrading", "sectionTitle": "Upgrading", @@ -4407,7 +4407,7 @@ "kind": "section" }, { - "id": "359-release-notes/0.23#features", + "id": "360-release-notes/0.23#features", "title": "Zero 0.23", "searchTitle": "Features", "sectionTitle": "Features", @@ -4417,7 +4417,7 @@ "kind": "section" }, { - "id": "360-release-notes/0.23#fixes", + "id": "361-release-notes/0.23#fixes", "title": "Zero 0.23", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -4427,7 +4427,7 @@ "kind": "section" }, { - "id": "361-release-notes/0.23#zbugs", + "id": "362-release-notes/0.23#zbugs", "title": "Zero 0.23", "searchTitle": "zbugs", "sectionTitle": "zbugs", @@ -4437,7 +4437,7 @@ "kind": "section" }, { - "id": "362-release-notes/0.23#breaking-changes", + "id": "363-release-notes/0.23#breaking-changes", "title": "Zero 0.23", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -4477,7 +4477,7 @@ "kind": "page" }, { - "id": "363-release-notes/0.24#installation", + "id": "364-release-notes/0.24#installation", "title": "Zero 0.24", "searchTitle": "Installation", "sectionTitle": "Installation", @@ -4487,7 +4487,7 @@ "kind": "section" }, { - "id": "364-release-notes/0.24#features", + "id": "365-release-notes/0.24#features", "title": "Zero 0.24", "searchTitle": "Features", "sectionTitle": "Features", @@ -4497,7 +4497,7 @@ "kind": "section" }, { - "id": "365-release-notes/0.24#fixes", + "id": "366-release-notes/0.24#fixes", "title": "Zero 0.24", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -4507,7 +4507,7 @@ "kind": "section" }, { - "id": "366-release-notes/0.24#breaking-changes", + "id": "367-release-notes/0.24#breaking-changes", "title": "Zero 0.24", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -4517,7 +4517,7 @@ "kind": "section" }, { - "id": "367-release-notes/0.24#example-upgrades", + "id": "368-release-notes/0.24#example-upgrades", "title": "Zero 0.24", "searchTitle": "Example Upgrades", "sectionTitle": "Example Upgrades", @@ -4565,7 +4565,7 @@ "kind": "page" }, { - "id": "368-release-notes/0.25#installation", + "id": "369-release-notes/0.25#installation", "title": "Zero 0.25", "searchTitle": "Installation", "sectionTitle": "Installation", @@ -4575,7 +4575,7 @@ "kind": "section" }, { - "id": "369-release-notes/0.25#overview", + "id": "370-release-notes/0.25#overview", "title": "Zero 0.25", "searchTitle": "Overview", "sectionTitle": "Overview", @@ -4585,7 +4585,7 @@ "kind": "section" }, { - "id": "370-release-notes/0.25#upgrading", + "id": "371-release-notes/0.25#upgrading", "title": "Zero 0.25", "searchTitle": "Upgrading", "sectionTitle": "Upgrading", @@ -4595,7 +4595,7 @@ "kind": "section" }, { - "id": "371-release-notes/0.25#features", + "id": "372-release-notes/0.25#features", "title": "Zero 0.25", "searchTitle": "Features", "sectionTitle": "Features", @@ -4605,7 +4605,7 @@ "kind": "section" }, { - "id": "372-release-notes/0.25#performance", + "id": "373-release-notes/0.25#performance", "title": "Zero 0.25", "searchTitle": "Performance", "sectionTitle": "Performance", @@ -4615,7 +4615,7 @@ "kind": "section" }, { - "id": "373-release-notes/0.25#fixes", + "id": "374-release-notes/0.25#fixes", "title": "Zero 0.25", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -4625,7 +4625,7 @@ "kind": "section" }, { - "id": "374-release-notes/0.25#breaking-changes", + "id": "375-release-notes/0.25#breaking-changes", "title": "Zero 0.25", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -4661,7 +4661,7 @@ "kind": "page" }, { - "id": "375-release-notes/0.26#installation", + "id": "376-release-notes/0.26#installation", "title": "Zero 0.26", "searchTitle": "Installation", "sectionTitle": "Installation", @@ -4671,7 +4671,7 @@ "kind": "section" }, { - "id": "376-release-notes/0.26#features", + "id": "377-release-notes/0.26#features", "title": "Zero 0.26", "searchTitle": "Features", "sectionTitle": "Features", @@ -4681,7 +4681,7 @@ "kind": "section" }, { - "id": "377-release-notes/0.26#fixes", + "id": "378-release-notes/0.26#fixes", "title": "Zero 0.26", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -4691,7 +4691,7 @@ "kind": "section" }, { - "id": "378-release-notes/0.26#breaking-changes", + "id": "379-release-notes/0.26#breaking-changes", "title": "Zero 0.26", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -4735,7 +4735,7 @@ "kind": "page" }, { - "id": "379-release-notes/0.3#install", + "id": "380-release-notes/0.3#install", "title": "Zero 0.3", "searchTitle": "Install", "sectionTitle": "Install", @@ -4745,7 +4745,7 @@ "kind": "section" }, { - "id": "380-release-notes/0.3#breaking-changes", + "id": "381-release-notes/0.3#breaking-changes", "title": "Zero 0.3", "searchTitle": "Breaking changes", "sectionTitle": "Breaking changes", @@ -4755,7 +4755,7 @@ "kind": "section" }, { - "id": "381-release-notes/0.3#features", + "id": "382-release-notes/0.3#features", "title": "Zero 0.3", "searchTitle": "Features", "sectionTitle": "Features", @@ -4765,7 +4765,7 @@ "kind": "section" }, { - "id": "382-release-notes/0.3#fixes", + "id": "383-release-notes/0.3#fixes", "title": "Zero 0.3", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -4775,7 +4775,7 @@ "kind": "section" }, { - "id": "383-release-notes/0.3#docs", + "id": "384-release-notes/0.3#docs", "title": "Zero 0.3", "searchTitle": "Docs", "sectionTitle": "Docs", @@ -4785,7 +4785,7 @@ "kind": "section" }, { - "id": "384-release-notes/0.3#zbugs", + "id": "385-release-notes/0.3#zbugs", "title": "Zero 0.3", "searchTitle": "zbugs", "sectionTitle": "zbugs", @@ -4829,7 +4829,7 @@ "kind": "page" }, { - "id": "385-release-notes/0.4#install", + "id": "386-release-notes/0.4#install", "title": "Zero 0.4", "searchTitle": "Install", "sectionTitle": "Install", @@ -4839,7 +4839,7 @@ "kind": "section" }, { - "id": "386-release-notes/0.4#breaking-changes", + "id": "387-release-notes/0.4#breaking-changes", "title": "Zero 0.4", "searchTitle": "Breaking changes", "sectionTitle": "Breaking changes", @@ -4849,7 +4849,7 @@ "kind": "section" }, { - "id": "387-release-notes/0.4#added-or--and--and-not-to-zql-documentation", + "id": "388-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 +4859,7 @@ "kind": "section" }, { - "id": "388-release-notes/0.4#fixes", + "id": "389-release-notes/0.4#fixes", "title": "Zero 0.4", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -4869,7 +4869,7 @@ "kind": "section" }, { - "id": "389-release-notes/0.4#docs", + "id": "390-release-notes/0.4#docs", "title": "Zero 0.4", "searchTitle": "Docs", "sectionTitle": "Docs", @@ -4879,7 +4879,7 @@ "kind": "section" }, { - "id": "390-release-notes/0.4#zbugs", + "id": "391-release-notes/0.4#zbugs", "title": "Zero 0.4", "searchTitle": "zbugs", "sectionTitle": "zbugs", @@ -4923,7 +4923,7 @@ "kind": "page" }, { - "id": "391-release-notes/0.5#install", + "id": "392-release-notes/0.5#install", "title": "Zero 0.5", "searchTitle": "Install", "sectionTitle": "Install", @@ -4933,7 +4933,7 @@ "kind": "section" }, { - "id": "392-release-notes/0.5#breaking-changes", + "id": "393-release-notes/0.5#breaking-changes", "title": "Zero 0.5", "searchTitle": "Breaking changes", "sectionTitle": "Breaking changes", @@ -4943,7 +4943,7 @@ "kind": "section" }, { - "id": "393-release-notes/0.5#features", + "id": "394-release-notes/0.5#features", "title": "Zero 0.5", "searchTitle": "Features", "sectionTitle": "Features", @@ -4953,7 +4953,7 @@ "kind": "section" }, { - "id": "394-release-notes/0.5#fixes", + "id": "395-release-notes/0.5#fixes", "title": "Zero 0.5", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -4963,7 +4963,7 @@ "kind": "section" }, { - "id": "395-release-notes/0.5#docs", + "id": "396-release-notes/0.5#docs", "title": "Zero 0.5", "searchTitle": "Docs", "sectionTitle": "Docs", @@ -4973,7 +4973,7 @@ "kind": "section" }, { - "id": "396-release-notes/0.5#zbugs", + "id": "397-release-notes/0.5#zbugs", "title": "Zero 0.5", "searchTitle": "zbugs", "sectionTitle": "zbugs", @@ -5017,7 +5017,7 @@ "kind": "page" }, { - "id": "397-release-notes/0.6#install", + "id": "398-release-notes/0.6#install", "title": "Zero 0.6", "searchTitle": "Install", "sectionTitle": "Install", @@ -5027,7 +5027,7 @@ "kind": "section" }, { - "id": "398-release-notes/0.6#upgrade-guide", + "id": "399-release-notes/0.6#upgrade-guide", "title": "Zero 0.6", "searchTitle": "Upgrade Guide", "sectionTitle": "Upgrade Guide", @@ -5037,7 +5037,7 @@ "kind": "section" }, { - "id": "399-release-notes/0.6#breaking-changes", + "id": "400-release-notes/0.6#breaking-changes", "title": "Zero 0.6", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -5047,7 +5047,7 @@ "kind": "section" }, { - "id": "400-release-notes/0.6#features", + "id": "401-release-notes/0.6#features", "title": "Zero 0.6", "searchTitle": "Features", "sectionTitle": "Features", @@ -5057,7 +5057,7 @@ "kind": "section" }, { - "id": "401-release-notes/0.6#zbugs", + "id": "402-release-notes/0.6#zbugs", "title": "Zero 0.6", "searchTitle": "zbugs", "sectionTitle": "zbugs", @@ -5067,7 +5067,7 @@ "kind": "section" }, { - "id": "402-release-notes/0.6#docs", + "id": "403-release-notes/0.6#docs", "title": "Zero 0.6", "searchTitle": "Docs", "sectionTitle": "Docs", @@ -5107,7 +5107,7 @@ "kind": "page" }, { - "id": "403-release-notes/0.7#install", + "id": "404-release-notes/0.7#install", "title": "Zero 0.7", "searchTitle": "Install", "sectionTitle": "Install", @@ -5117,7 +5117,7 @@ "kind": "section" }, { - "id": "404-release-notes/0.7#features", + "id": "405-release-notes/0.7#features", "title": "Zero 0.7", "searchTitle": "Features", "sectionTitle": "Features", @@ -5127,7 +5127,7 @@ "kind": "section" }, { - "id": "405-release-notes/0.7#breaking-changes", + "id": "406-release-notes/0.7#breaking-changes", "title": "Zero 0.7", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -5137,7 +5137,7 @@ "kind": "section" }, { - "id": "406-release-notes/0.7#zbugs", + "id": "407-release-notes/0.7#zbugs", "title": "Zero 0.7", "searchTitle": "zbugs", "sectionTitle": "zbugs", @@ -5147,7 +5147,7 @@ "kind": "section" }, { - "id": "407-release-notes/0.7#docs", + "id": "408-release-notes/0.7#docs", "title": "Zero 0.7", "searchTitle": "Docs", "sectionTitle": "Docs", @@ -5183,7 +5183,7 @@ "kind": "page" }, { - "id": "408-release-notes/0.8#install", + "id": "409-release-notes/0.8#install", "title": "Zero 0.8", "searchTitle": "Install", "sectionTitle": "Install", @@ -5193,7 +5193,7 @@ "kind": "section" }, { - "id": "409-release-notes/0.8#features", + "id": "410-release-notes/0.8#features", "title": "Zero 0.8", "searchTitle": "Features", "sectionTitle": "Features", @@ -5203,7 +5203,7 @@ "kind": "section" }, { - "id": "410-release-notes/0.8#fixes", + "id": "411-release-notes/0.8#fixes", "title": "Zero 0.8", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -5213,7 +5213,7 @@ "kind": "section" }, { - "id": "411-release-notes/0.8#breaking-changes", + "id": "412-release-notes/0.8#breaking-changes", "title": "Zero 0.8", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -5249,7 +5249,7 @@ "kind": "page" }, { - "id": "412-release-notes/0.9#install", + "id": "413-release-notes/0.9#install", "title": "Zero 0.9", "searchTitle": "Install", "sectionTitle": "Install", @@ -5259,7 +5259,7 @@ "kind": "section" }, { - "id": "413-release-notes/0.9#features", + "id": "414-release-notes/0.9#features", "title": "Zero 0.9", "searchTitle": "Features", "sectionTitle": "Features", @@ -5269,7 +5269,7 @@ "kind": "section" }, { - "id": "414-release-notes/0.9#fixes", + "id": "415-release-notes/0.9#fixes", "title": "Zero 0.9", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -5279,7 +5279,7 @@ "kind": "section" }, { - "id": "415-release-notes/0.9#breaking-changes", + "id": "416-release-notes/0.9#breaking-changes", "title": "Zero 0.9", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -5319,7 +5319,7 @@ "kind": "page" }, { - "id": "416-release-notes/1.0#installation", + "id": "417-release-notes/1.0#installation", "title": "Zero 1.0", "searchTitle": "Installation", "sectionTitle": "Installation", @@ -5329,7 +5329,7 @@ "kind": "section" }, { - "id": "417-release-notes/1.0#overview", + "id": "418-release-notes/1.0#overview", "title": "Zero 1.0", "searchTitle": "Overview", "sectionTitle": "Overview", @@ -5339,7 +5339,7 @@ "kind": "section" }, { - "id": "418-release-notes/1.0#features", + "id": "419-release-notes/1.0#features", "title": "Zero 1.0", "searchTitle": "Features", "sectionTitle": "Features", @@ -5349,7 +5349,7 @@ "kind": "section" }, { - "id": "419-release-notes/1.0#fixes", + "id": "420-release-notes/1.0#fixes", "title": "Zero 1.0", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -5359,7 +5359,7 @@ "kind": "section" }, { - "id": "420-release-notes/1.0#breaking-changes", + "id": "421-release-notes/1.0#breaking-changes", "title": "Zero 1.0", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -5395,7 +5395,7 @@ "kind": "page" }, { - "id": "421-release-notes/1.1#installation", + "id": "422-release-notes/1.1#installation", "title": "Zero 1.1", "searchTitle": "Installation", "sectionTitle": "Installation", @@ -5405,7 +5405,7 @@ "kind": "section" }, { - "id": "422-release-notes/1.1#features", + "id": "423-release-notes/1.1#features", "title": "Zero 1.1", "searchTitle": "Features", "sectionTitle": "Features", @@ -5415,7 +5415,7 @@ "kind": "section" }, { - "id": "423-release-notes/1.1#fixes", + "id": "424-release-notes/1.1#fixes", "title": "Zero 1.1", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -5425,7 +5425,7 @@ "kind": "section" }, { - "id": "424-release-notes/1.1#breaking-changes", + "id": "425-release-notes/1.1#breaking-changes", "title": "Zero 1.1", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -5465,7 +5465,7 @@ "kind": "page" }, { - "id": "425-release-notes/1.2#installation", + "id": "426-release-notes/1.2#installation", "title": "Zero 1.2", "searchTitle": "Installation", "sectionTitle": "Installation", @@ -5475,7 +5475,7 @@ "kind": "section" }, { - "id": "426-release-notes/1.2#features", + "id": "427-release-notes/1.2#features", "title": "Zero 1.2", "searchTitle": "Features", "sectionTitle": "Features", @@ -5485,7 +5485,7 @@ "kind": "section" }, { - "id": "427-release-notes/1.2#performance", + "id": "428-release-notes/1.2#performance", "title": "Zero 1.2", "searchTitle": "Performance", "sectionTitle": "Performance", @@ -5495,7 +5495,7 @@ "kind": "section" }, { - "id": "428-release-notes/1.2#fixes", + "id": "429-release-notes/1.2#fixes", "title": "Zero 1.2", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -5505,7 +5505,7 @@ "kind": "section" }, { - "id": "429-release-notes/1.2#breaking-changes", + "id": "430-release-notes/1.2#breaking-changes", "title": "Zero 1.2", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -5545,7 +5545,7 @@ "kind": "page" }, { - "id": "430-release-notes/1.3#installation", + "id": "431-release-notes/1.3#installation", "title": "Zero 1.3", "searchTitle": "Installation", "sectionTitle": "Installation", @@ -5555,7 +5555,7 @@ "kind": "section" }, { - "id": "431-release-notes/1.3#features", + "id": "432-release-notes/1.3#features", "title": "Zero 1.3", "searchTitle": "Features", "sectionTitle": "Features", @@ -5565,7 +5565,7 @@ "kind": "section" }, { - "id": "432-release-notes/1.3#performance", + "id": "433-release-notes/1.3#performance", "title": "Zero 1.3", "searchTitle": "Performance", "sectionTitle": "Performance", @@ -5575,7 +5575,7 @@ "kind": "section" }, { - "id": "433-release-notes/1.3#fixes", + "id": "434-release-notes/1.3#fixes", "title": "Zero 1.3", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -5585,7 +5585,7 @@ "kind": "section" }, { - "id": "434-release-notes/1.3#breaking-changes", + "id": "435-release-notes/1.3#breaking-changes", "title": "Zero 1.3", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -5633,7 +5633,7 @@ "kind": "page" }, { - "id": "435-release-notes/1.4#installation", + "id": "436-release-notes/1.4#installation", "title": "Zero 1.4", "searchTitle": "Installation", "sectionTitle": "Installation", @@ -5643,7 +5643,7 @@ "kind": "section" }, { - "id": "436-release-notes/1.4#upgrading", + "id": "437-release-notes/1.4#upgrading", "title": "Zero 1.4", "searchTitle": "Upgrading", "sectionTitle": "Upgrading", @@ -5653,7 +5653,7 @@ "kind": "section" }, { - "id": "437-release-notes/1.4#userid-anon", + "id": "438-release-notes/1.4#userid-anon", "title": "Zero 1.4", "searchTitle": "userID: \"anon\"", "sectionTitle": "userID: \"anon\"", @@ -5663,7 +5663,7 @@ "kind": "section" }, { - "id": "438-release-notes/1.4#features", + "id": "439-release-notes/1.4#features", "title": "Zero 1.4", "searchTitle": "Features", "sectionTitle": "Features", @@ -5673,7 +5673,7 @@ "kind": "section" }, { - "id": "439-release-notes/1.4#performance", + "id": "440-release-notes/1.4#performance", "title": "Zero 1.4", "searchTitle": "Performance", "sectionTitle": "Performance", @@ -5683,7 +5683,7 @@ "kind": "section" }, { - "id": "440-release-notes/1.4#fixes", + "id": "441-release-notes/1.4#fixes", "title": "Zero 1.4", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -5693,7 +5693,7 @@ "kind": "section" }, { - "id": "441-release-notes/1.4#breaking-changes", + "id": "442-release-notes/1.4#breaking-changes", "title": "Zero 1.4", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -5745,7 +5745,7 @@ "kind": "page" }, { - "id": "442-release-notes/1.5#installation", + "id": "443-release-notes/1.5#installation", "title": "Zero 1.5", "searchTitle": "Installation", "sectionTitle": "Installation", @@ -5755,7 +5755,7 @@ "kind": "section" }, { - "id": "443-release-notes/1.5#upgrading", + "id": "444-release-notes/1.5#upgrading", "title": "Zero 1.5", "searchTitle": "Upgrading", "sectionTitle": "Upgrading", @@ -5765,7 +5765,7 @@ "kind": "section" }, { - "id": "444-release-notes/1.5#authenticated-client-groups", + "id": "445-release-notes/1.5#authenticated-client-groups", "title": "Zero 1.5", "searchTitle": "Authenticated Client Groups", "sectionTitle": "Authenticated Client Groups", @@ -5775,7 +5775,7 @@ "kind": "section" }, { - "id": "445-release-notes/1.5#deploy-order", + "id": "446-release-notes/1.5#deploy-order", "title": "Zero 1.5", "searchTitle": "Deploy Order", "sectionTitle": "Deploy Order", @@ -5785,7 +5785,7 @@ "kind": "section" }, { - "id": "446-release-notes/1.5#features", + "id": "447-release-notes/1.5#features", "title": "Zero 1.5", "searchTitle": "Features", "sectionTitle": "Features", @@ -5795,7 +5795,7 @@ "kind": "section" }, { - "id": "447-release-notes/1.5#performance", + "id": "448-release-notes/1.5#performance", "title": "Zero 1.5", "searchTitle": "Performance", "sectionTitle": "Performance", @@ -5805,7 +5805,7 @@ "kind": "section" }, { - "id": "448-release-notes/1.5#fixes", + "id": "449-release-notes/1.5#fixes", "title": "Zero 1.5", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -5815,7 +5815,7 @@ "kind": "section" }, { - "id": "449-release-notes/1.5#breaking-changes", + "id": "450-release-notes/1.5#breaking-changes", "title": "Zero 1.5", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -5863,7 +5863,7 @@ "kind": "page" }, { - "id": "450-release-notes/1.6#installation", + "id": "451-release-notes/1.6#installation", "title": "Zero 1.6", "searchTitle": "Installation", "sectionTitle": "Installation", @@ -5873,7 +5873,7 @@ "kind": "section" }, { - "id": "451-release-notes/1.6#upgrading", + "id": "452-release-notes/1.6#upgrading", "title": "Zero 1.6", "searchTitle": "Upgrading", "sectionTitle": "Upgrading", @@ -5883,7 +5883,7 @@ "kind": "section" }, { - "id": "452-release-notes/1.6#planetscale-failover", + "id": "453-release-notes/1.6#planetscale-failover", "title": "Zero 1.6", "searchTitle": "PlanetScale Failover", "sectionTitle": "PlanetScale Failover", @@ -5893,7 +5893,7 @@ "kind": "section" }, { - "id": "453-release-notes/1.6#features", + "id": "454-release-notes/1.6#features", "title": "Zero 1.6", "searchTitle": "Features", "sectionTitle": "Features", @@ -5903,7 +5903,7 @@ "kind": "section" }, { - "id": "454-release-notes/1.6#performance", + "id": "455-release-notes/1.6#performance", "title": "Zero 1.6", "searchTitle": "Performance", "sectionTitle": "Performance", @@ -5913,7 +5913,7 @@ "kind": "section" }, { - "id": "455-release-notes/1.6#fixes", + "id": "456-release-notes/1.6#fixes", "title": "Zero 1.6", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -5923,7 +5923,7 @@ "kind": "section" }, { - "id": "456-release-notes/1.6#breaking-changes", + "id": "457-release-notes/1.6#breaking-changes", "title": "Zero 1.6", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -5975,7 +5975,7 @@ "kind": "page" }, { - "id": "457-release-notes/1.7#installation", + "id": "458-release-notes/1.7#installation", "title": "Zero 1.7", "searchTitle": "Installation", "sectionTitle": "Installation", @@ -5985,7 +5985,7 @@ "kind": "section" }, { - "id": "458-release-notes/1.7#overview", + "id": "459-release-notes/1.7#overview", "title": "Zero 1.7", "searchTitle": "Overview", "sectionTitle": "Overview", @@ -5995,7 +5995,7 @@ "kind": "section" }, { - "id": "459-release-notes/1.7#features", + "id": "460-release-notes/1.7#features", "title": "Zero 1.7", "searchTitle": "Features", "sectionTitle": "Features", @@ -6005,7 +6005,7 @@ "kind": "section" }, { - "id": "460-release-notes/1.7#performance", + "id": "461-release-notes/1.7#performance", "title": "Zero 1.7", "searchTitle": "Performance", "sectionTitle": "Performance", @@ -6015,7 +6015,7 @@ "kind": "section" }, { - "id": "461-release-notes/1.7#replication", + "id": "462-release-notes/1.7#replication", "title": "Zero 1.7", "searchTitle": "Replication", "sectionTitle": "Replication", @@ -6025,7 +6025,7 @@ "kind": "section" }, { - "id": "462-release-notes/1.7#flipped-exists-queries", + "id": "463-release-notes/1.7#flipped-exists-queries", "title": "Zero 1.7", "searchTitle": "Flipped Exists Queries", "sectionTitle": "Flipped Exists Queries", @@ -6035,7 +6035,7 @@ "kind": "section" }, { - "id": "463-release-notes/1.7#fixes", + "id": "464-release-notes/1.7#fixes", "title": "Zero 1.7", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -6045,7 +6045,7 @@ "kind": "section" }, { - "id": "464-release-notes/1.7#breaking-changes", + "id": "465-release-notes/1.7#breaking-changes", "title": "Zero 1.7", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -6055,16 +6055,152 @@ "kind": "section" }, { - "id": "60-release-notes", + "id": "60-release-notes/1.8", + "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.", + "headings": [ + { + "text": "Installation", + "id": "installation" + }, + { + "text": "Overview", + "id": "overview" + }, + { + "text": "Features", + "id": "features" + }, + { + "text": "Performance", + "id": "performance" + }, + { + "text": "Replicating Large Transactions", + "id": "replicating-large-transactions" + }, + { + "text": "Maintaining orderBy() + limit() Queries", + "id": "maintaining-orderby--limit-queries" + }, + { + "text": "Running Local ZQL Queries", + "id": "running-local-zql-queries" + }, + { + "text": "Fixes", + "id": "fixes" + }, + { + "text": "Breaking Changes", + "id": "breaking-changes" + } + ], + "kind": "page" + }, + { + "id": "466-release-notes/1.8#installation", + "title": "Zero 1.8", + "searchTitle": "Installation", + "sectionTitle": "Installation", + "sectionId": "installation", + "url": "/docs/release-notes/1.8", + "content": "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", + "kind": "section" + }, + { + "id": "467-release-notes/1.8#overview", + "title": "Zero 1.8", + "searchTitle": "Overview", + "sectionTitle": "Overview", + "sectionId": "overview", + "url": "/docs/release-notes/1.8", + "content": "Zero 1.8 improves observability, performance, and reliability.", + "kind": "section" + }, + { + "id": "468-release-notes/1.8#features", + "title": "Zero 1.8", + "searchTitle": "Features", + "sectionTitle": "Features", + "sectionId": "features", + "url": "/docs/release-notes/1.8", + "content": "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)", + "kind": "section" + }, + { + "id": "469-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.", + "kind": "section" + }, + { + "id": "470-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.", + "kind": "section" + }, + { + "id": "471-release-notes/1.8#maintaining-orderby--limit-queries", + "title": "Zero 1.8", + "searchTitle": "Maintaining orderBy() + limit() Queries", + "sectionTitle": "Maintaining orderBy() + limit() Queries", + "sectionId": "maintaining-orderby--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.", + "kind": "section" + }, + { + "id": "472-release-notes/1.8#running-local-zql-queries", + "title": "Zero 1.8", + "searchTitle": "Running Local ZQL Queries", + "sectionTitle": "Running Local ZQL Queries", + "sectionId": "running-local-zql-queries", + "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.", + "kind": "section" + }, + { + "id": "473-release-notes/1.8#fixes", + "title": "Zero 1.8", + "searchTitle": "Fixes", + "sectionTitle": "Fixes", + "sectionId": "fixes", + "url": "/docs/release-notes/1.8", + "content": "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.", + "kind": "section" + }, + { + "id": "474-release-notes/1.8#breaking-changes", + "title": "Zero 1.8", + "searchTitle": "Breaking Changes", + "sectionTitle": "Breaking Changes", + "sectionId": "breaking-changes", + "url": "/docs/release-notes/1.8", + "content": "None.", + "kind": "section" + }, + { + "id": "61-release-notes", "title": "Release Notes", "searchTitle": "Release Notes", "url": "/docs/release-notes", - "content": "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.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": "61-reporting-bugs", + "id": "62-reporting-bugs", "title": "Reporting Bugs", "searchTitle": "Reporting Bugs", "url": "/docs/reporting-bugs", @@ -6082,7 +6218,7 @@ "kind": "page" }, { - "id": "465-reporting-bugs#zbugs", + "id": "475-reporting-bugs#zbugs", "title": "Reporting Bugs", "searchTitle": "zbugs", "sectionTitle": "zbugs", @@ -6092,7 +6228,7 @@ "kind": "section" }, { - "id": "466-reporting-bugs#discord", + "id": "476-reporting-bugs#discord", "title": "Reporting Bugs", "searchTitle": "Discord", "sectionTitle": "Discord", @@ -6102,7 +6238,7 @@ "kind": "section" }, { - "id": "62-rest", + "id": "63-rest", "title": "REST", "searchTitle": "REST", "url": "/docs/rest", @@ -6128,7 +6264,7 @@ "kind": "page" }, { - "id": "467-rest#pattern", + "id": "477-rest#pattern", "title": "REST", "searchTitle": "Pattern", "sectionTitle": "Pattern", @@ -6138,7 +6274,7 @@ "kind": "section" }, { - "id": "468-rest#tanstack-start-example", + "id": "478-rest#tanstack-start-example", "title": "REST", "searchTitle": "TanStack Start Example", "sectionTitle": "TanStack Start Example", @@ -6148,7 +6284,7 @@ "kind": "section" }, { - "id": "469-rest#openapi-generation", + "id": "479-rest#openapi-generation", "title": "REST", "searchTitle": "OpenAPI Generation", "sectionTitle": "OpenAPI Generation", @@ -6158,7 +6294,7 @@ "kind": "section" }, { - "id": "470-rest#full-working-example", + "id": "480-rest#full-working-example", "title": "REST", "searchTitle": "Full Working Example", "sectionTitle": "Full Working Example", @@ -6168,7 +6304,7 @@ "kind": "section" }, { - "id": "63-roadmap", + "id": "64-roadmap", "title": "Roadmap", "searchTitle": "Roadmap", "url": "/docs/roadmap", @@ -6186,7 +6322,7 @@ "kind": "page" }, { - "id": "471-roadmap#q4-2025", + "id": "481-roadmap#q4-2025", "title": "Roadmap", "searchTitle": "Q4 2025", "sectionTitle": "Q4 2025", @@ -6196,7 +6332,7 @@ "kind": "section" }, { - "id": "472-roadmap#beyond", + "id": "482-roadmap#beyond", "title": "Roadmap", "searchTitle": "Beyond", "sectionTitle": "Beyond", @@ -6206,7 +6342,7 @@ "kind": "section" }, { - "id": "64-samples", + "id": "65-samples", "title": "Samples", "searchTitle": "Samples", "url": "/docs/samples", @@ -6232,7 +6368,7 @@ "kind": "page" }, { - "id": "473-samples#gigabugs", + "id": "483-samples#gigabugs", "title": "Samples", "searchTitle": "Gigabugs", "sectionTitle": "Gigabugs", @@ -6242,7 +6378,7 @@ "kind": "section" }, { - "id": "474-samples#ztunes", + "id": "484-samples#ztunes", "title": "Samples", "searchTitle": "ztunes", "sectionTitle": "ztunes", @@ -6252,7 +6388,7 @@ "kind": "section" }, { - "id": "475-samples#zslack", + "id": "485-samples#zslack", "title": "Samples", "searchTitle": "zslack", "sectionTitle": "zslack", @@ -6262,7 +6398,7 @@ "kind": "section" }, { - "id": "476-samples#zero-music", + "id": "486-samples#zero-music", "title": "Samples", "searchTitle": "zero-music", "sectionTitle": "zero-music", @@ -6272,7 +6408,7 @@ "kind": "section" }, { - "id": "65-schema", + "id": "66-schema", "title": "Zero Schema", "searchTitle": "Zero Schema", "url": "/docs/schema", @@ -6398,7 +6534,7 @@ "kind": "page" }, { - "id": "477-schema#generating-from-database", + "id": "487-schema#generating-from-database", "title": "Zero Schema", "searchTitle": "Generating from Database", "sectionTitle": "Generating from Database", @@ -6408,7 +6544,7 @@ "kind": "section" }, { - "id": "478-schema#writing-by-hand", + "id": "488-schema#writing-by-hand", "title": "Zero Schema", "searchTitle": "Writing by Hand", "sectionTitle": "Writing by Hand", @@ -6418,7 +6554,7 @@ "kind": "section" }, { - "id": "479-schema#table-schemas", + "id": "489-schema#table-schemas", "title": "Zero Schema", "searchTitle": "Table Schemas", "sectionTitle": "Table Schemas", @@ -6428,7 +6564,7 @@ "kind": "section" }, { - "id": "480-schema#name-mapping", + "id": "490-schema#name-mapping", "title": "Zero Schema", "searchTitle": "Name Mapping", "sectionTitle": "Name Mapping", @@ -6438,7 +6574,7 @@ "kind": "section" }, { - "id": "481-schema#multiple-schemas", + "id": "491-schema#multiple-schemas", "title": "Zero Schema", "searchTitle": "Multiple Schemas", "sectionTitle": "Multiple Schemas", @@ -6448,7 +6584,7 @@ "kind": "section" }, { - "id": "482-schema#optional-columns", + "id": "492-schema#optional-columns", "title": "Zero Schema", "searchTitle": "Optional Columns", "sectionTitle": "Optional Columns", @@ -6458,7 +6594,7 @@ "kind": "section" }, { - "id": "483-schema#enumerations", + "id": "493-schema#enumerations", "title": "Zero Schema", "searchTitle": "Enumerations", "sectionTitle": "Enumerations", @@ -6468,7 +6604,7 @@ "kind": "section" }, { - "id": "484-schema#custom-json-types", + "id": "494-schema#custom-json-types", "title": "Zero Schema", "searchTitle": "Custom JSON Types", "sectionTitle": "Custom JSON Types", @@ -6478,7 +6614,7 @@ "kind": "section" }, { - "id": "485-schema#compound-primary-keys", + "id": "495-schema#compound-primary-keys", "title": "Zero Schema", "searchTitle": "Compound Primary Keys", "sectionTitle": "Compound Primary Keys", @@ -6488,7 +6624,7 @@ "kind": "section" }, { - "id": "486-schema#relationships", + "id": "496-schema#relationships", "title": "Zero Schema", "searchTitle": "Relationships", "sectionTitle": "Relationships", @@ -6498,7 +6634,7 @@ "kind": "section" }, { - "id": "487-schema#many-to-many-relationships", + "id": "497-schema#many-to-many-relationships", "title": "Zero Schema", "searchTitle": "Many-to-Many Relationships", "sectionTitle": "Many-to-Many Relationships", @@ -6508,7 +6644,7 @@ "kind": "section" }, { - "id": "488-schema#compound-keys-relationships", + "id": "498-schema#compound-keys-relationships", "title": "Zero Schema", "searchTitle": "Compound Keys Relationships", "sectionTitle": "Compound Keys Relationships", @@ -6518,7 +6654,7 @@ "kind": "section" }, { - "id": "489-schema#circular-relationships", + "id": "499-schema#circular-relationships", "title": "Zero Schema", "searchTitle": "Circular Relationships", "sectionTitle": "Circular Relationships", @@ -6528,7 +6664,7 @@ "kind": "section" }, { - "id": "490-schema#database-schemas", + "id": "500-schema#database-schemas", "title": "Zero Schema", "searchTitle": "Database Schemas", "sectionTitle": "Database Schemas", @@ -6538,7 +6674,7 @@ "kind": "section" }, { - "id": "491-schema#register-schema-type", + "id": "501-schema#register-schema-type", "title": "Zero Schema", "searchTitle": "Register Schema Type", "sectionTitle": "Register Schema Type", @@ -6548,7 +6684,7 @@ "kind": "section" }, { - "id": "492-schema#schema-changes", + "id": "502-schema#schema-changes", "title": "Zero Schema", "searchTitle": "Schema Changes", "sectionTitle": "Schema Changes", @@ -6558,7 +6694,7 @@ "kind": "section" }, { - "id": "493-schema#development", + "id": "503-schema#development", "title": "Zero Schema", "searchTitle": "Development", "sectionTitle": "Development", @@ -6568,7 +6704,7 @@ "kind": "section" }, { - "id": "494-schema#production", + "id": "504-schema#production", "title": "Zero Schema", "searchTitle": "Production", "sectionTitle": "Production", @@ -6578,7 +6714,7 @@ "kind": "section" }, { - "id": "495-schema#expand-changes", + "id": "505-schema#expand-changes", "title": "Zero Schema", "searchTitle": "Expand Changes", "sectionTitle": "Expand Changes", @@ -6588,7 +6724,7 @@ "kind": "section" }, { - "id": "496-schema#contract-changes", + "id": "506-schema#contract-changes", "title": "Zero Schema", "searchTitle": "Contract Changes", "sectionTitle": "Contract Changes", @@ -6598,7 +6734,7 @@ "kind": "section" }, { - "id": "497-schema#compound-changes", + "id": "507-schema#compound-changes", "title": "Zero Schema", "searchTitle": "Compound Changes", "sectionTitle": "Compound Changes", @@ -6608,7 +6744,7 @@ "kind": "section" }, { - "id": "498-schema#examples", + "id": "508-schema#examples", "title": "Zero Schema", "searchTitle": "Examples", "sectionTitle": "Examples", @@ -6618,7 +6754,7 @@ "kind": "section" }, { - "id": "499-schema#adding-a-column", + "id": "509-schema#adding-a-column", "title": "Zero Schema", "searchTitle": "Adding a Column", "sectionTitle": "Adding a Column", @@ -6628,7 +6764,7 @@ "kind": "section" }, { - "id": "500-schema#removing-a-column", + "id": "510-schema#removing-a-column", "title": "Zero Schema", "searchTitle": "Removing a Column", "sectionTitle": "Removing a Column", @@ -6638,7 +6774,7 @@ "kind": "section" }, { - "id": "501-schema#renaming-a-column", + "id": "511-schema#renaming-a-column", "title": "Zero Schema", "searchTitle": "Renaming a Column", "sectionTitle": "Renaming a Column", @@ -6648,7 +6784,7 @@ "kind": "section" }, { - "id": "502-schema#making-a-column-optional", + "id": "512-schema#making-a-column-optional", "title": "Zero Schema", "searchTitle": "Making a Column Optional", "sectionTitle": "Making a Column Optional", @@ -6658,7 +6794,7 @@ "kind": "section" }, { - "id": "503-schema#quick-reference", + "id": "513-schema#quick-reference", "title": "Zero Schema", "searchTitle": "Quick Reference", "sectionTitle": "Quick Reference", @@ -6668,7 +6804,7 @@ "kind": "section" }, { - "id": "504-schema#backfill", + "id": "514-schema#backfill", "title": "Zero Schema", "searchTitle": "Backfill", "sectionTitle": "Backfill", @@ -6678,7 +6814,7 @@ "kind": "section" }, { - "id": "505-schema#monitoring-backfill-progress", + "id": "515-schema#monitoring-backfill-progress", "title": "Zero Schema", "searchTitle": "Monitoring Backfill Progress", "sectionTitle": "Monitoring Backfill Progress", @@ -6688,12 +6824,16 @@ "kind": "section" }, { - "id": "66-self-host", + "id": "67-self-host", "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. 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. 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", + "id": "docker-images" + }, { "text": "Minimum Viable Strategy", "id": "minimum-viable-strategy" @@ -6746,7 +6886,17 @@ "kind": "page" }, { - "id": "506-self-host#minimum-viable-strategy", + "id": "516-self-host#docker-images", + "title": "Self-Hosting Zero", + "searchTitle": "Docker Images", + "sectionTitle": "Docker Images", + "sectionId": "docker-images", + "url": "/docs/self-host", + "content": "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}", + "kind": "section" + }, + { + "id": "517-self-host#minimum-viable-strategy", "title": "Self-Hosting Zero", "searchTitle": "Minimum Viable Strategy", "sectionTitle": "Minimum Viable Strategy", @@ -6756,7 +6906,7 @@ "kind": "section" }, { - "id": "507-self-host#maximal-strategy", + "id": "518-self-host#maximal-strategy", "title": "Self-Hosting Zero", "searchTitle": "Maximal Strategy", "sectionTitle": "Maximal Strategy", @@ -6766,7 +6916,7 @@ "kind": "section" }, { - "id": "508-self-host#replica-lifecycle", + "id": "519-self-host#replica-lifecycle", "title": "Self-Hosting Zero", "searchTitle": "Replica Lifecycle", "sectionTitle": "Replica Lifecycle", @@ -6776,7 +6926,7 @@ "kind": "section" }, { - "id": "509-self-host#performance", + "id": "520-self-host#performance", "title": "Self-Hosting Zero", "searchTitle": "Performance", "sectionTitle": "Performance", @@ -6786,7 +6936,7 @@ "kind": "section" }, { - "id": "510-self-host#hydration", + "id": "521-self-host#hydration", "title": "Self-Hosting Zero", "searchTitle": "Hydration", "sectionTitle": "Hydration", @@ -6796,7 +6946,7 @@ "kind": "section" }, { - "id": "511-self-host#ivm-advancement", + "id": "522-self-host#ivm-advancement", "title": "Self-Hosting Zero", "searchTitle": "IVM advancement", "sectionTitle": "IVM advancement", @@ -6806,7 +6956,7 @@ "kind": "section" }, { - "id": "512-self-host#system-level", + "id": "523-self-host#system-level", "title": "Self-Hosting Zero", "searchTitle": "System-level", "sectionTitle": "System-level", @@ -6816,7 +6966,7 @@ "kind": "section" }, { - "id": "513-self-host#networking", + "id": "524-self-host#networking", "title": "Self-Hosting Zero", "searchTitle": "Networking", "sectionTitle": "Networking", @@ -6826,7 +6976,7 @@ "kind": "section" }, { - "id": "514-self-host#sticky-sessions", + "id": "525-self-host#sticky-sessions", "title": "Self-Hosting Zero", "searchTitle": "Sticky Sessions", "sectionTitle": "Sticky Sessions", @@ -6836,7 +6986,7 @@ "kind": "section" }, { - "id": "515-self-host#rolling-updates", + "id": "526-self-host#rolling-updates", "title": "Self-Hosting Zero", "searchTitle": "Rolling Updates", "sectionTitle": "Rolling Updates", @@ -6846,7 +6996,7 @@ "kind": "section" }, { - "id": "516-self-host#clientserver-version-compatibility", + "id": "527-self-host#clientserver-version-compatibility", "title": "Self-Hosting Zero", "searchTitle": "Client/Server Version Compatibility", "sectionTitle": "Client/Server Version Compatibility", @@ -6856,7 +7006,7 @@ "kind": "section" }, { - "id": "517-self-host#configuration", + "id": "528-self-host#configuration", "title": "Self-Hosting Zero", "searchTitle": "Configuration", "sectionTitle": "Configuration", @@ -6866,7 +7016,7 @@ "kind": "section" }, { - "id": "67-server-zql", + "id": "68-server-zql", "title": "ZQL on the Server", "searchTitle": "ZQL on the Server", "url": "/docs/server-zql", @@ -6892,7 +7042,7 @@ "kind": "page" }, { - "id": "518-server-zql#creating-a-database", + "id": "529-server-zql#creating-a-database", "title": "ZQL on the Server", "searchTitle": "Creating a Database", "sectionTitle": "Creating a Database", @@ -6902,7 +7052,7 @@ "kind": "section" }, { - "id": "519-server-zql#custom-database", + "id": "530-server-zql#custom-database", "title": "ZQL on the Server", "searchTitle": "Custom Database", "sectionTitle": "Custom Database", @@ -6912,7 +7062,7 @@ "kind": "section" }, { - "id": "520-server-zql#running-zql", + "id": "531-server-zql#running-zql", "title": "ZQL on the Server", "searchTitle": "Running ZQL", "sectionTitle": "Running ZQL", @@ -6922,7 +7072,7 @@ "kind": "section" }, { - "id": "521-server-zql#ssr", + "id": "532-server-zql#ssr", "title": "ZQL on the Server", "searchTitle": "SSR", "sectionTitle": "SSR", @@ -6932,7 +7082,7 @@ "kind": "section" }, { - "id": "68-solidjs", + "id": "69-solidjs", "title": "SolidJS", "searchTitle": "SolidJS", "url": "/docs/solidjs", @@ -6954,7 +7104,7 @@ "kind": "page" }, { - "id": "522-solidjs#setup", + "id": "533-solidjs#setup", "title": "SolidJS", "searchTitle": "Setup", "sectionTitle": "Setup", @@ -6964,7 +7114,7 @@ "kind": "section" }, { - "id": "523-solidjs#usage", + "id": "534-solidjs#usage", "title": "SolidJS", "searchTitle": "Usage", "sectionTitle": "Usage", @@ -6974,7 +7124,7 @@ "kind": "section" }, { - "id": "524-solidjs#examples", + "id": "535-solidjs#examples", "title": "SolidJS", "searchTitle": "Examples", "sectionTitle": "Examples", @@ -6984,7 +7134,7 @@ "kind": "section" }, { - "id": "69-status", + "id": "70-status", "title": "Project Status", "searchTitle": "Project Status", "url": "/docs/status", @@ -7010,7 +7160,7 @@ "kind": "page" }, { - "id": "525-status#breaking-changes", + "id": "536-status#breaking-changes", "title": "Project Status", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -7020,7 +7170,7 @@ "kind": "section" }, { - "id": "526-status#roadmap", + "id": "537-status#roadmap", "title": "Project Status", "searchTitle": "Roadmap", "sectionTitle": "Roadmap", @@ -7030,7 +7180,7 @@ "kind": "section" }, { - "id": "527-status#2026", + "id": "538-status#2026", "title": "Project Status", "searchTitle": "2026", "sectionTitle": "2026", @@ -7040,7 +7190,7 @@ "kind": "section" }, { - "id": "528-status#soon", + "id": "539-status#soon", "title": "Project Status", "searchTitle": "Soon", "sectionTitle": "Soon", @@ -7050,7 +7200,7 @@ "kind": "section" }, { - "id": "70-sync", + "id": "71-sync", "title": "What is Sync?", "searchTitle": "What is Sync?", "url": "/docs/sync", @@ -7072,7 +7222,7 @@ "kind": "page" }, { - "id": "529-sync#problem", + "id": "540-sync#problem", "title": "What is Sync?", "searchTitle": "Problem", "sectionTitle": "Problem", @@ -7082,7 +7232,7 @@ "kind": "section" }, { - "id": "530-sync#solution", + "id": "541-sync#solution", "title": "What is Sync?", "searchTitle": "Solution", "sectionTitle": "Solution", @@ -7092,7 +7242,7 @@ "kind": "section" }, { - "id": "531-sync#history-of-sync", + "id": "542-sync#history-of-sync", "title": "What is Sync?", "searchTitle": "History of Sync", "sectionTitle": "History of Sync", @@ -7102,7 +7252,7 @@ "kind": "section" }, { - "id": "71-tutorial", + "id": "72-tutorial", "title": "Tutorial", "searchTitle": "Tutorial", "url": "/docs/tutorial", @@ -7176,7 +7326,7 @@ "kind": "page" }, { - "id": "532-tutorial#setup", + "id": "543-tutorial#setup", "title": "Tutorial", "searchTitle": "Setup", "sectionTitle": "Setup", @@ -7186,7 +7336,7 @@ "kind": "section" }, { - "id": "533-tutorial#create-a-project", + "id": "544-tutorial#create-a-project", "title": "Tutorial", "searchTitle": "Create a Project", "sectionTitle": "Create a Project", @@ -7196,7 +7346,7 @@ "kind": "section" }, { - "id": "534-tutorial#set-up-your-database", + "id": "545-tutorial#set-up-your-database", "title": "Tutorial", "searchTitle": "Set Up Your Database", "sectionTitle": "Set Up Your Database", @@ -7206,7 +7356,7 @@ "kind": "section" }, { - "id": "535-tutorial#install-and-run-zero-cache", + "id": "546-tutorial#install-and-run-zero-cache", "title": "Tutorial", "searchTitle": "Install and Run Zero-Cache", "sectionTitle": "Install and Run Zero-Cache", @@ -7216,7 +7366,7 @@ "kind": "section" }, { - "id": "536-tutorial#integrate-zero", + "id": "547-tutorial#integrate-zero", "title": "Tutorial", "searchTitle": "Integrate Zero", "sectionTitle": "Integrate Zero", @@ -7226,7 +7376,7 @@ "kind": "section" }, { - "id": "537-tutorial#set-up-your-zero-schema", + "id": "548-tutorial#set-up-your-zero-schema", "title": "Tutorial", "searchTitle": "Set Up Your Zero Schema", "sectionTitle": "Set Up Your Zero Schema", @@ -7236,7 +7386,7 @@ "kind": "section" }, { - "id": "538-tutorial#set-up-the-zero-client", + "id": "549-tutorial#set-up-the-zero-client", "title": "Tutorial", "searchTitle": "Set Up the Zero Client", "sectionTitle": "Set Up the Zero Client", @@ -7246,7 +7396,7 @@ "kind": "section" }, { - "id": "539-tutorial#sync-data", + "id": "550-tutorial#sync-data", "title": "Tutorial", "searchTitle": "Sync Data", "sectionTitle": "Sync Data", @@ -7256,7 +7406,7 @@ "kind": "section" }, { - "id": "540-tutorial#define-query", + "id": "551-tutorial#define-query", "title": "Tutorial", "searchTitle": "Define Query", "sectionTitle": "Define Query", @@ -7266,7 +7416,7 @@ "kind": "section" }, { - "id": "541-tutorial#add-query-endpoint", + "id": "552-tutorial#add-query-endpoint", "title": "Tutorial", "searchTitle": "Add Query Endpoint", "sectionTitle": "Add Query Endpoint", @@ -7276,7 +7426,7 @@ "kind": "section" }, { - "id": "542-tutorial#invoke-query", + "id": "553-tutorial#invoke-query", "title": "Tutorial", "searchTitle": "Invoke Query", "sectionTitle": "Invoke Query", @@ -7286,7 +7436,7 @@ "kind": "section" }, { - "id": "543-tutorial#mutate-data", + "id": "554-tutorial#mutate-data", "title": "Tutorial", "searchTitle": "Mutate Data", "sectionTitle": "Mutate Data", @@ -7296,7 +7446,7 @@ "kind": "section" }, { - "id": "544-tutorial#define-mutators", + "id": "555-tutorial#define-mutators", "title": "Tutorial", "searchTitle": "Define Mutators", "sectionTitle": "Define Mutators", @@ -7306,7 +7456,7 @@ "kind": "section" }, { - "id": "545-tutorial#add-mutate-endpoint", + "id": "556-tutorial#add-mutate-endpoint", "title": "Tutorial", "searchTitle": "Add Mutate Endpoint", "sectionTitle": "Add Mutate Endpoint", @@ -7316,7 +7466,7 @@ "kind": "section" }, { - "id": "546-tutorial#invoke-mutators", + "id": "557-tutorial#invoke-mutators", "title": "Tutorial", "searchTitle": "Invoke Mutators", "sectionTitle": "Invoke Mutators", @@ -7326,7 +7476,7 @@ "kind": "section" }, { - "id": "547-tutorial#next-steps", + "id": "558-tutorial#next-steps", "title": "Tutorial", "searchTitle": "Next Steps", "sectionTitle": "Next Steps", @@ -7336,7 +7486,7 @@ "kind": "section" }, { - "id": "72-when-to-use", + "id": "73-when-to-use", "title": "When To Use Zero", "searchTitle": "When To Use Zero", "url": "/docs/when-to-use", @@ -7402,7 +7552,7 @@ "kind": "page" }, { - "id": "548-when-to-use#zero-might-be-a-good-fit", + "id": "559-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", @@ -7412,7 +7562,7 @@ "kind": "section" }, { - "id": "549-when-to-use#you-want-to-sync-only-a-small-subset-of-data-to-client", + "id": "560-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", @@ -7422,7 +7572,7 @@ "kind": "section" }, { - "id": "550-when-to-use#you-need-fine-grained-read-or-write-permissions", + "id": "561-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", @@ -7432,7 +7582,7 @@ "kind": "section" }, { - "id": "551-when-to-use#you-are-building-a-traditional-client-server-web-app", + "id": "562-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", @@ -7442,7 +7592,7 @@ "kind": "section" }, { - "id": "552-when-to-use#you-use-postgresql", + "id": "563-when-to-use#you-use-postgresql", "title": "When To Use Zero", "searchTitle": "You use PostgreSQL", "sectionTitle": "You use PostgreSQL", @@ -7452,7 +7602,7 @@ "kind": "section" }, { - "id": "553-when-to-use#your-app-is-broadly-like-linear", + "id": "564-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\"", @@ -7462,7 +7612,7 @@ "kind": "section" }, { - "id": "554-when-to-use#interaction-performance-is-very-important-to-you", + "id": "565-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", @@ -7472,7 +7622,7 @@ "kind": "section" }, { - "id": "555-when-to-use#zero-might-not-be-a-good-fit", + "id": "566-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", @@ -7482,7 +7632,7 @@ "kind": "section" }, { - "id": "556-when-to-use#you-need-the-privacy-or-data-ownership-benefits-of-local-first", + "id": "567-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", @@ -7492,7 +7642,7 @@ "kind": "section" }, { - "id": "557-when-to-use#you-need-to-support-offline-writes-or-long-periods-offline", + "id": "568-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", @@ -7502,7 +7652,7 @@ "kind": "section" }, { - "id": "558-when-to-use#you-are-building-a-native-mobile-app", + "id": "569-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", @@ -7512,7 +7662,7 @@ "kind": "section" }, { - "id": "559-when-to-use#the-total-backend-dataset-is--100gb", + "id": "570-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", @@ -7522,7 +7672,7 @@ "kind": "section" }, { - "id": "560-when-to-use#zero-might-not-be-a-good-fit-yet", + "id": "571-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", @@ -7532,7 +7682,7 @@ "kind": "section" }, { - "id": "561-when-to-use#alternatives", + "id": "572-when-to-use#alternatives", "title": "When To Use Zero", "searchTitle": "Alternatives", "sectionTitle": "Alternatives", @@ -7542,11 +7692,11 @@ "kind": "section" }, { - "id": "73-zero-cache-config", + "id": "74-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 list of custom request headers that zero-cache is allowed 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 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 list of custom request headers that zero-cache is allowed 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 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 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", "headings": [ { "text": "Required Flags", @@ -7744,6 +7894,10 @@ "text": "Mutate Allowed Client Headers", "id": "mutate-allowed-client-headers" }, + { + "text": "Mutate Allowed Request Headers", + "id": "mutate-allowed-request-headers" + }, { "text": "Mutate Forward Cookies", "id": "mutate-forward-cookies" @@ -7780,6 +7934,10 @@ "text": "Query Allowed Client Headers", "id": "query-allowed-client-headers" }, + { + "text": "Query Allowed Request Headers", + "id": "query-allowed-request-headers" + }, { "text": "Query Forward Cookies", "id": "query-forward-cookies" @@ -7876,7 +8034,7 @@ "kind": "page" }, { - "id": "562-zero-cache-config#required-flags", + "id": "573-zero-cache-config#required-flags", "title": "zero-cache Config", "searchTitle": "Required Flags", "sectionTitle": "Required Flags", @@ -7886,7 +8044,7 @@ "kind": "section" }, { - "id": "563-zero-cache-config#upstream-db", + "id": "574-zero-cache-config#upstream-db", "title": "zero-cache Config", "searchTitle": "Upstream DB", "sectionTitle": "Upstream DB", @@ -7896,7 +8054,7 @@ "kind": "section" }, { - "id": "564-zero-cache-config#admin-password", + "id": "575-zero-cache-config#admin-password", "title": "zero-cache Config", "searchTitle": "Admin Password", "sectionTitle": "Admin Password", @@ -7906,17 +8064,17 @@ "kind": "section" }, { - "id": "565-zero-cache-config#optional-flags", + "id": "576-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 list of custom request headers that zero-cache is allowed 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 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 list of custom request headers that zero-cache is allowed 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 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 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", "kind": "section" }, { - "id": "566-zero-cache-config#app-id", + "id": "577-zero-cache-config#app-id", "title": "zero-cache Config", "searchTitle": "App ID", "sectionTitle": "App ID", @@ -7926,7 +8084,7 @@ "kind": "section" }, { - "id": "567-zero-cache-config#app-publications", + "id": "578-zero-cache-config#app-publications", "title": "zero-cache Config", "searchTitle": "App Publications", "sectionTitle": "App Publications", @@ -7936,7 +8094,7 @@ "kind": "section" }, { - "id": "568-zero-cache-config#auth-revalidate-interval-seconds", + "id": "579-zero-cache-config#auth-revalidate-interval-seconds", "title": "zero-cache Config", "searchTitle": "Auth Revalidate Interval Seconds", "sectionTitle": "Auth Revalidate Interval Seconds", @@ -7946,7 +8104,7 @@ "kind": "section" }, { - "id": "569-zero-cache-config#auth-retransform-interval-seconds", + "id": "580-zero-cache-config#auth-retransform-interval-seconds", "title": "zero-cache Config", "searchTitle": "Auth Retransform Interval Seconds", "sectionTitle": "Auth Retransform Interval Seconds", @@ -7956,7 +8114,7 @@ "kind": "section" }, { - "id": "570-zero-cache-config#auto-reset", + "id": "581-zero-cache-config#auto-reset", "title": "zero-cache Config", "searchTitle": "Auto Reset", "sectionTitle": "Auto Reset", @@ -7966,7 +8124,7 @@ "kind": "section" }, { - "id": "571-zero-cache-config#change-db", + "id": "582-zero-cache-config#change-db", "title": "zero-cache Config", "searchTitle": "Change DB", "sectionTitle": "Change DB", @@ -7976,7 +8134,7 @@ "kind": "section" }, { - "id": "572-zero-cache-config#change-max-connections", + "id": "583-zero-cache-config#change-max-connections", "title": "zero-cache Config", "searchTitle": "Change Max Connections", "sectionTitle": "Change Max Connections", @@ -7986,7 +8144,7 @@ "kind": "section" }, { - "id": "573-zero-cache-config#change-streamer-back-pressure-limit-heap-proportion", + "id": "584-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", @@ -7996,7 +8154,7 @@ "kind": "section" }, { - "id": "574-zero-cache-config#change-streamer-flow-control-consensus-padding-seconds", + "id": "585-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", @@ -8006,7 +8164,7 @@ "kind": "section" }, { - "id": "575-zero-cache-config#change-streamer-mode", + "id": "586-zero-cache-config#change-streamer-mode", "title": "zero-cache Config", "searchTitle": "Change Streamer Mode", "sectionTitle": "Change Streamer Mode", @@ -8016,7 +8174,7 @@ "kind": "section" }, { - "id": "576-zero-cache-config#change-streamer-port", + "id": "587-zero-cache-config#change-streamer-port", "title": "zero-cache Config", "searchTitle": "Change Streamer Port", "sectionTitle": "Change Streamer Port", @@ -8026,7 +8184,7 @@ "kind": "section" }, { - "id": "577-zero-cache-config#change-streamer-startup-delay-ms", + "id": "588-zero-cache-config#change-streamer-startup-delay-ms", "title": "zero-cache Config", "searchTitle": "Change Streamer Startup Delay (ms)", "sectionTitle": "Change Streamer Startup Delay (ms)", @@ -8036,7 +8194,7 @@ "kind": "section" }, { - "id": "578-zero-cache-config#change-streamer-uri", + "id": "589-zero-cache-config#change-streamer-uri", "title": "zero-cache Config", "searchTitle": "Change Streamer URI", "sectionTitle": "Change Streamer URI", @@ -8046,7 +8204,7 @@ "kind": "section" }, { - "id": "579-zero-cache-config#cvr-db", + "id": "590-zero-cache-config#cvr-db", "title": "zero-cache Config", "searchTitle": "CVR DB", "sectionTitle": "CVR DB", @@ -8056,7 +8214,7 @@ "kind": "section" }, { - "id": "580-zero-cache-config#cvr-garbage-collection-inactivity-threshold-hours", + "id": "591-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", @@ -8066,7 +8224,7 @@ "kind": "section" }, { - "id": "581-zero-cache-config#cvr-garbage-collection-initial-batch-size", + "id": "592-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", @@ -8076,7 +8234,7 @@ "kind": "section" }, { - "id": "582-zero-cache-config#cvr-garbage-collection-initial-interval-seconds", + "id": "593-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", @@ -8086,7 +8244,7 @@ "kind": "section" }, { - "id": "583-zero-cache-config#cvr-max-connections", + "id": "594-zero-cache-config#cvr-max-connections", "title": "zero-cache Config", "searchTitle": "CVR Max Connections", "sectionTitle": "CVR Max Connections", @@ -8096,7 +8254,7 @@ "kind": "section" }, { - "id": "584-zero-cache-config#enable-query-planner", + "id": "595-zero-cache-config#enable-query-planner", "title": "zero-cache Config", "searchTitle": "Enable Query Planner", "sectionTitle": "Enable Query Planner", @@ -8106,7 +8264,7 @@ "kind": "section" }, { - "id": "585-zero-cache-config#enable-crud-mutations", + "id": "596-zero-cache-config#enable-crud-mutations", "title": "zero-cache Config", "searchTitle": "Enable CRUD Mutations", "sectionTitle": "Enable CRUD Mutations", @@ -8116,7 +8274,7 @@ "kind": "section" }, { - "id": "586-zero-cache-config#enable-telemetry", + "id": "597-zero-cache-config#enable-telemetry", "title": "zero-cache Config", "searchTitle": "Enable Telemetry", "sectionTitle": "Enable Telemetry", @@ -8126,7 +8284,7 @@ "kind": "section" }, { - "id": "587-zero-cache-config#initial-sync-table-copy-workers", + "id": "598-zero-cache-config#initial-sync-table-copy-workers", "title": "zero-cache Config", "searchTitle": "Initial Sync Table Copy Workers", "sectionTitle": "Initial Sync Table Copy Workers", @@ -8136,7 +8294,7 @@ "kind": "section" }, { - "id": "588-zero-cache-config#lazy-startup", + "id": "599-zero-cache-config#lazy-startup", "title": "zero-cache Config", "searchTitle": "Lazy Startup", "sectionTitle": "Lazy Startup", @@ -8146,7 +8304,7 @@ "kind": "section" }, { - "id": "589-zero-cache-config#litestream-backup-url", + "id": "600-zero-cache-config#litestream-backup-url", "title": "zero-cache Config", "searchTitle": "Litestream Backup URL", "sectionTitle": "Litestream Backup URL", @@ -8156,7 +8314,7 @@ "kind": "section" }, { - "id": "590-zero-cache-config#litestream-endpoint", + "id": "601-zero-cache-config#litestream-endpoint", "title": "zero-cache Config", "searchTitle": "Litestream Endpoint", "sectionTitle": "Litestream Endpoint", @@ -8166,7 +8324,7 @@ "kind": "section" }, { - "id": "591-zero-cache-config#litestream-checkpoint-threshold-mb", + "id": "602-zero-cache-config#litestream-checkpoint-threshold-mb", "title": "zero-cache Config", "searchTitle": "Litestream Checkpoint Threshold MB", "sectionTitle": "Litestream Checkpoint Threshold MB", @@ -8176,7 +8334,7 @@ "kind": "section" }, { - "id": "592-zero-cache-config#litestream-config-path", + "id": "603-zero-cache-config#litestream-config-path", "title": "zero-cache Config", "searchTitle": "Litestream Config Path", "sectionTitle": "Litestream Config Path", @@ -8186,7 +8344,7 @@ "kind": "section" }, { - "id": "593-zero-cache-config#litestream-executable", + "id": "604-zero-cache-config#litestream-executable", "title": "zero-cache Config", "searchTitle": "Litestream Executable", "sectionTitle": "Litestream Executable", @@ -8196,7 +8354,7 @@ "kind": "section" }, { - "id": "594-zero-cache-config#litestream-incremental-backup-interval-minutes", + "id": "605-zero-cache-config#litestream-incremental-backup-interval-minutes", "title": "zero-cache Config", "searchTitle": "Litestream Incremental Backup Interval Minutes", "sectionTitle": "Litestream Incremental Backup Interval Minutes", @@ -8206,7 +8364,7 @@ "kind": "section" }, { - "id": "595-zero-cache-config#litestream-maximum-checkpoint-page-count", + "id": "606-zero-cache-config#litestream-maximum-checkpoint-page-count", "title": "zero-cache Config", "searchTitle": "Litestream Maximum Checkpoint Page Count", "sectionTitle": "Litestream Maximum Checkpoint Page Count", @@ -8216,7 +8374,7 @@ "kind": "section" }, { - "id": "596-zero-cache-config#litestream-minimum-checkpoint-page-count", + "id": "607-zero-cache-config#litestream-minimum-checkpoint-page-count", "title": "zero-cache Config", "searchTitle": "Litestream Minimum Checkpoint Page Count", "sectionTitle": "Litestream Minimum Checkpoint Page Count", @@ -8226,7 +8384,7 @@ "kind": "section" }, { - "id": "597-zero-cache-config#litestream-multipart-concurrency", + "id": "608-zero-cache-config#litestream-multipart-concurrency", "title": "zero-cache Config", "searchTitle": "Litestream Multipart Concurrency", "sectionTitle": "Litestream Multipart Concurrency", @@ -8236,7 +8394,7 @@ "kind": "section" }, { - "id": "598-zero-cache-config#litestream-multipart-size", + "id": "609-zero-cache-config#litestream-multipart-size", "title": "zero-cache Config", "searchTitle": "Litestream Multipart Size", "sectionTitle": "Litestream Multipart Size", @@ -8246,7 +8404,7 @@ "kind": "section" }, { - "id": "599-zero-cache-config#litestream-log-level", + "id": "610-zero-cache-config#litestream-log-level", "title": "zero-cache Config", "searchTitle": "Litestream Log Level", "sectionTitle": "Litestream Log Level", @@ -8256,7 +8414,7 @@ "kind": "section" }, { - "id": "600-zero-cache-config#litestream-port", + "id": "611-zero-cache-config#litestream-port", "title": "zero-cache Config", "searchTitle": "Litestream Port", "sectionTitle": "Litestream Port", @@ -8266,7 +8424,7 @@ "kind": "section" }, { - "id": "601-zero-cache-config#litestream-region", + "id": "612-zero-cache-config#litestream-region", "title": "zero-cache Config", "searchTitle": "Litestream Region", "sectionTitle": "Litestream Region", @@ -8276,7 +8434,7 @@ "kind": "section" }, { - "id": "602-zero-cache-config#litestream-restore-parallelism", + "id": "613-zero-cache-config#litestream-restore-parallelism", "title": "zero-cache Config", "searchTitle": "Litestream Restore Parallelism", "sectionTitle": "Litestream Restore Parallelism", @@ -8286,7 +8444,7 @@ "kind": "section" }, { - "id": "603-zero-cache-config#litestream-snapshot-backup-interval-hours", + "id": "614-zero-cache-config#litestream-snapshot-backup-interval-hours", "title": "zero-cache Config", "searchTitle": "Litestream Snapshot Backup Interval Hours", "sectionTitle": "Litestream Snapshot Backup Interval Hours", @@ -8296,7 +8454,7 @@ "kind": "section" }, { - "id": "604-zero-cache-config#log-format", + "id": "615-zero-cache-config#log-format", "title": "zero-cache Config", "searchTitle": "Log Format", "sectionTitle": "Log Format", @@ -8306,7 +8464,7 @@ "kind": "section" }, { - "id": "605-zero-cache-config#log-ivm-sampling", + "id": "616-zero-cache-config#log-ivm-sampling", "title": "zero-cache Config", "searchTitle": "Log IVM Sampling", "sectionTitle": "Log IVM Sampling", @@ -8316,7 +8474,7 @@ "kind": "section" }, { - "id": "606-zero-cache-config#log-level", + "id": "617-zero-cache-config#log-level", "title": "zero-cache Config", "searchTitle": "Log Level", "sectionTitle": "Log Level", @@ -8326,7 +8484,7 @@ "kind": "section" }, { - "id": "607-zero-cache-config#log-slow-hydrate-threshold", + "id": "618-zero-cache-config#log-slow-hydrate-threshold", "title": "zero-cache Config", "searchTitle": "Log Slow Hydrate Threshold", "sectionTitle": "Log Slow Hydrate Threshold", @@ -8336,7 +8494,7 @@ "kind": "section" }, { - "id": "608-zero-cache-config#log-slow-row-threshold", + "id": "619-zero-cache-config#log-slow-row-threshold", "title": "zero-cache Config", "searchTitle": "Log Slow Row Threshold", "sectionTitle": "Log Slow Row Threshold", @@ -8346,7 +8504,7 @@ "kind": "section" }, { - "id": "609-zero-cache-config#mutate-api-key", + "id": "620-zero-cache-config#mutate-api-key", "title": "zero-cache Config", "searchTitle": "Mutate API Key", "sectionTitle": "Mutate API Key", @@ -8356,17 +8514,27 @@ "kind": "section" }, { - "id": "610-zero-cache-config#mutate-allowed-client-headers", + "id": "621-zero-cache-config#mutate-allowed-client-headers", "title": "zero-cache Config", "searchTitle": "Mutate Allowed Client Headers", "sectionTitle": "Mutate Allowed Client Headers", "sectionId": "mutate-allowed-client-headers", "url": "/docs/zero-cache-config", - "content": "Comma-separated list of custom request headers that zero-cache is allowed 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", + "content": "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", + "kind": "section" + }, + { + "id": "622-zero-cache-config#mutate-allowed-request-headers", + "title": "zero-cache Config", + "searchTitle": "Mutate Allowed Request Headers", + "sectionTitle": "Mutate Allowed Request Headers", + "sectionId": "mutate-allowed-request-headers", + "url": "/docs/zero-cache-config", + "content": "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", "kind": "section" }, { - "id": "611-zero-cache-config#mutate-forward-cookies", + "id": "623-zero-cache-config#mutate-forward-cookies", "title": "zero-cache Config", "searchTitle": "Mutate Forward Cookies", "sectionTitle": "Mutate Forward Cookies", @@ -8376,7 +8544,7 @@ "kind": "section" }, { - "id": "612-zero-cache-config#mutate-url", + "id": "624-zero-cache-config#mutate-url", "title": "zero-cache Config", "searchTitle": "Mutate URL", "sectionTitle": "Mutate URL", @@ -8386,7 +8554,7 @@ "kind": "section" }, { - "id": "613-zero-cache-config#number-of-sync-workers", + "id": "625-zero-cache-config#number-of-sync-workers", "title": "zero-cache Config", "searchTitle": "Number of Sync Workers", "sectionTitle": "Number of Sync Workers", @@ -8396,7 +8564,7 @@ "kind": "section" }, { - "id": "614-zero-cache-config#per-user-mutation-limit-max", + "id": "626-zero-cache-config#per-user-mutation-limit-max", "title": "zero-cache Config", "searchTitle": "Per User Mutation Limit Max", "sectionTitle": "Per User Mutation Limit Max", @@ -8406,7 +8574,7 @@ "kind": "section" }, { - "id": "615-zero-cache-config#per-user-mutation-limit-window-ms", + "id": "627-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)", @@ -8416,7 +8584,7 @@ "kind": "section" }, { - "id": "616-zero-cache-config#pg-replication-slot-failover", + "id": "628-zero-cache-config#pg-replication-slot-failover", "title": "zero-cache Config", "searchTitle": "PG Replication Slot Failover", "sectionTitle": "PG Replication Slot Failover", @@ -8426,7 +8594,7 @@ "kind": "section" }, { - "id": "617-zero-cache-config#port", + "id": "629-zero-cache-config#port", "title": "zero-cache Config", "searchTitle": "Port", "sectionTitle": "Port", @@ -8436,7 +8604,7 @@ "kind": "section" }, { - "id": "618-zero-cache-config#query-api-key", + "id": "630-zero-cache-config#query-api-key", "title": "zero-cache Config", "searchTitle": "Query API Key", "sectionTitle": "Query API Key", @@ -8446,17 +8614,27 @@ "kind": "section" }, { - "id": "619-zero-cache-config#query-allowed-client-headers", + "id": "631-zero-cache-config#query-allowed-client-headers", "title": "zero-cache Config", "searchTitle": "Query Allowed Client Headers", "sectionTitle": "Query Allowed Client Headers", "sectionId": "query-allowed-client-headers", "url": "/docs/zero-cache-config", - "content": "Comma-separated list of custom request headers that zero-cache is allowed 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", + "content": "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", + "kind": "section" + }, + { + "id": "632-zero-cache-config#query-allowed-request-headers", + "title": "zero-cache Config", + "searchTitle": "Query Allowed Request Headers", + "sectionTitle": "Query Allowed Request Headers", + "sectionId": "query-allowed-request-headers", + "url": "/docs/zero-cache-config", + "content": "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", "kind": "section" }, { - "id": "620-zero-cache-config#query-forward-cookies", + "id": "633-zero-cache-config#query-forward-cookies", "title": "zero-cache Config", "searchTitle": "Query Forward Cookies", "sectionTitle": "Query Forward Cookies", @@ -8466,7 +8644,7 @@ "kind": "section" }, { - "id": "621-zero-cache-config#query-hydration-stats", + "id": "634-zero-cache-config#query-hydration-stats", "title": "zero-cache Config", "searchTitle": "Query Hydration Stats", "sectionTitle": "Query Hydration Stats", @@ -8476,7 +8654,7 @@ "kind": "section" }, { - "id": "622-zero-cache-config#query-url", + "id": "635-zero-cache-config#query-url", "title": "zero-cache Config", "searchTitle": "Query URL", "sectionTitle": "Query URL", @@ -8486,7 +8664,7 @@ "kind": "section" }, { - "id": "623-zero-cache-config#replica-file", + "id": "636-zero-cache-config#replica-file", "title": "zero-cache Config", "searchTitle": "Replica File", "sectionTitle": "Replica File", @@ -8496,7 +8674,7 @@ "kind": "section" }, { - "id": "624-zero-cache-config#replica-vacuum-interval-hours", + "id": "637-zero-cache-config#replica-vacuum-interval-hours", "title": "zero-cache Config", "searchTitle": "Replica Vacuum Interval Hours", "sectionTitle": "Replica Vacuum Interval Hours", @@ -8506,7 +8684,7 @@ "kind": "section" }, { - "id": "625-zero-cache-config#replication-lag-report-interval-ms", + "id": "638-zero-cache-config#replication-lag-report-interval-ms", "title": "zero-cache Config", "searchTitle": "Replication Lag Report Interval (ms)", "sectionTitle": "Replication Lag Report Interval (ms)", @@ -8516,7 +8694,7 @@ "kind": "section" }, { - "id": "626-zero-cache-config#server-version", + "id": "639-zero-cache-config#server-version", "title": "zero-cache Config", "searchTitle": "Server Version", "sectionTitle": "Server Version", @@ -8526,7 +8704,7 @@ "kind": "section" }, { - "id": "627-zero-cache-config#shadow-sync-enabled", + "id": "640-zero-cache-config#shadow-sync-enabled", "title": "zero-cache Config", "searchTitle": "Shadow Sync Enabled", "sectionTitle": "Shadow Sync Enabled", @@ -8536,7 +8714,7 @@ "kind": "section" }, { - "id": "628-zero-cache-config#shadow-sync-interval-hours", + "id": "641-zero-cache-config#shadow-sync-interval-hours", "title": "zero-cache Config", "searchTitle": "Shadow Sync Interval Hours", "sectionTitle": "Shadow Sync Interval Hours", @@ -8546,7 +8724,7 @@ "kind": "section" }, { - "id": "629-zero-cache-config#shadow-sync-sample-rate", + "id": "642-zero-cache-config#shadow-sync-sample-rate", "title": "zero-cache Config", "searchTitle": "Shadow Sync Sample Rate", "sectionTitle": "Shadow Sync Sample Rate", @@ -8556,7 +8734,7 @@ "kind": "section" }, { - "id": "630-zero-cache-config#shadow-sync-max-rows-per-table", + "id": "643-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", @@ -8566,7 +8744,7 @@ "kind": "section" }, { - "id": "631-zero-cache-config#storage-db-temp-dir", + "id": "644-zero-cache-config#storage-db-temp-dir", "title": "zero-cache Config", "searchTitle": "Storage DB Temp Dir", "sectionTitle": "Storage DB Temp Dir", @@ -8576,7 +8754,7 @@ "kind": "section" }, { - "id": "632-zero-cache-config#task-id", + "id": "645-zero-cache-config#task-id", "title": "zero-cache Config", "searchTitle": "Task ID", "sectionTitle": "Task ID", @@ -8586,7 +8764,7 @@ "kind": "section" }, { - "id": "633-zero-cache-config#upstream-max-connections", + "id": "646-zero-cache-config#upstream-max-connections", "title": "zero-cache Config", "searchTitle": "Upstream Max Connections", "sectionTitle": "Upstream Max Connections", @@ -8596,7 +8774,7 @@ "kind": "section" }, { - "id": "634-zero-cache-config#upstream-pg-replication-slot-failover", + "id": "647-zero-cache-config#upstream-pg-replication-slot-failover", "title": "zero-cache Config", "searchTitle": "Upstream PG Replication Slot Failover", "sectionTitle": "Upstream PG Replication Slot Failover", @@ -8606,7 +8784,7 @@ "kind": "section" }, { - "id": "635-zero-cache-config#websocket-compression", + "id": "648-zero-cache-config#websocket-compression", "title": "zero-cache Config", "searchTitle": "Websocket Compression", "sectionTitle": "Websocket Compression", @@ -8616,7 +8794,7 @@ "kind": "section" }, { - "id": "636-zero-cache-config#websocket-compression-options", + "id": "649-zero-cache-config#websocket-compression-options", "title": "zero-cache Config", "searchTitle": "Websocket Compression Options", "sectionTitle": "Websocket Compression Options", @@ -8626,7 +8804,7 @@ "kind": "section" }, { - "id": "637-zero-cache-config#websocket-max-payload-bytes", + "id": "650-zero-cache-config#websocket-max-payload-bytes", "title": "zero-cache Config", "searchTitle": "Websocket Max Payload Bytes", "sectionTitle": "Websocket Max Payload Bytes", @@ -8636,7 +8814,7 @@ "kind": "section" }, { - "id": "638-zero-cache-config#yield-threshold-ms", + "id": "651-zero-cache-config#yield-threshold-ms", "title": "zero-cache Config", "searchTitle": "Yield Threshold (ms)", "sectionTitle": "Yield Threshold (ms)", @@ -8646,7 +8824,7 @@ "kind": "section" }, { - "id": "639-zero-cache-config#deprecated-flags", + "id": "652-zero-cache-config#deprecated-flags", "title": "zero-cache Config", "searchTitle": "Deprecated Flags", "sectionTitle": "Deprecated Flags", @@ -8656,7 +8834,7 @@ "kind": "section" }, { - "id": "640-zero-cache-config#auth-jwk", + "id": "653-zero-cache-config#auth-jwk", "title": "zero-cache Config", "searchTitle": "Auth JWK", "sectionTitle": "Auth JWK", @@ -8666,7 +8844,7 @@ "kind": "section" }, { - "id": "641-zero-cache-config#auth-jwks-url", + "id": "654-zero-cache-config#auth-jwks-url", "title": "zero-cache Config", "searchTitle": "Auth JWKS URL", "sectionTitle": "Auth JWKS URL", @@ -8676,7 +8854,7 @@ "kind": "section" }, { - "id": "642-zero-cache-config#auth-secret", + "id": "655-zero-cache-config#auth-secret", "title": "zero-cache Config", "searchTitle": "Auth Secret", "sectionTitle": "Auth Secret", @@ -8686,7 +8864,7 @@ "kind": "section" }, { - "id": "74-zql", + "id": "75-zql", "title": "ZQL", "searchTitle": "ZQL", "url": "/docs/zql", @@ -8796,7 +8974,7 @@ "kind": "page" }, { - "id": "643-zql#create-a-builder", + "id": "656-zql#create-a-builder", "title": "ZQL", "searchTitle": "Create a Builder", "sectionTitle": "Create a Builder", @@ -8806,7 +8984,7 @@ "kind": "section" }, { - "id": "644-zql#select", + "id": "657-zql#select", "title": "ZQL", "searchTitle": "Select", "sectionTitle": "Select", @@ -8816,7 +8994,7 @@ "kind": "section" }, { - "id": "645-zql#ordering", + "id": "658-zql#ordering", "title": "ZQL", "searchTitle": "Ordering", "sectionTitle": "Ordering", @@ -8826,7 +9004,7 @@ "kind": "section" }, { - "id": "646-zql#limit", + "id": "659-zql#limit", "title": "ZQL", "searchTitle": "Limit", "sectionTitle": "Limit", @@ -8836,7 +9014,7 @@ "kind": "section" }, { - "id": "647-zql#paging", + "id": "660-zql#paging", "title": "ZQL", "searchTitle": "Paging", "sectionTitle": "Paging", @@ -8846,7 +9024,7 @@ "kind": "section" }, { - "id": "648-zql#getting-a-single-result", + "id": "661-zql#getting-a-single-result", "title": "ZQL", "searchTitle": "Getting a Single Result", "sectionTitle": "Getting a Single Result", @@ -8856,7 +9034,7 @@ "kind": "section" }, { - "id": "649-zql#relationships", + "id": "662-zql#relationships", "title": "ZQL", "searchTitle": "Relationships", "sectionTitle": "Relationships", @@ -8866,7 +9044,7 @@ "kind": "section" }, { - "id": "650-zql#refining-relationships", + "id": "663-zql#refining-relationships", "title": "ZQL", "searchTitle": "Refining Relationships", "sectionTitle": "Refining Relationships", @@ -8876,7 +9054,7 @@ "kind": "section" }, { - "id": "651-zql#nested-relationships", + "id": "664-zql#nested-relationships", "title": "ZQL", "searchTitle": "Nested Relationships", "sectionTitle": "Nested Relationships", @@ -8886,7 +9064,7 @@ "kind": "section" }, { - "id": "652-zql#where", + "id": "665-zql#where", "title": "ZQL", "searchTitle": "Where", "sectionTitle": "Where", @@ -8896,7 +9074,7 @@ "kind": "section" }, { - "id": "653-zql#comparison-operators", + "id": "666-zql#comparison-operators", "title": "ZQL", "searchTitle": "Comparison Operators", "sectionTitle": "Comparison Operators", @@ -8906,7 +9084,7 @@ "kind": "section" }, { - "id": "654-zql#equals-is-the-default-comparison-operator", + "id": "667-zql#equals-is-the-default-comparison-operator", "title": "ZQL", "searchTitle": "Equals is the Default Comparison Operator", "sectionTitle": "Equals is the Default Comparison Operator", @@ -8916,7 +9094,7 @@ "kind": "section" }, { - "id": "655-zql#comparing-to-null", + "id": "668-zql#comparing-to-null", "title": "ZQL", "searchTitle": "Comparing to null", "sectionTitle": "Comparing to null", @@ -8926,7 +9104,7 @@ "kind": "section" }, { - "id": "656-zql#comparing-to-undefined", + "id": "669-zql#comparing-to-undefined", "title": "ZQL", "searchTitle": "Comparing to undefined", "sectionTitle": "Comparing to undefined", @@ -8936,7 +9114,7 @@ "kind": "section" }, { - "id": "657-zql#compound-filters", + "id": "670-zql#compound-filters", "title": "ZQL", "searchTitle": "Compound Filters", "sectionTitle": "Compound Filters", @@ -8946,7 +9124,7 @@ "kind": "section" }, { - "id": "658-zql#comparing-literal-values", + "id": "671-zql#comparing-literal-values", "title": "ZQL", "searchTitle": "Comparing Literal Values", "sectionTitle": "Comparing Literal Values", @@ -8956,7 +9134,7 @@ "kind": "section" }, { - "id": "659-zql#relationship-filters", + "id": "672-zql#relationship-filters", "title": "ZQL", "searchTitle": "Relationship Filters", "sectionTitle": "Relationship Filters", @@ -8966,7 +9144,7 @@ "kind": "section" }, { - "id": "660-zql#type-helpers", + "id": "673-zql#type-helpers", "title": "ZQL", "searchTitle": "Type Helpers", "sectionTitle": "Type Helpers", @@ -8976,7 +9154,7 @@ "kind": "section" }, { - "id": "661-zql#planning", + "id": "674-zql#planning", "title": "ZQL", "searchTitle": "Planning", "sectionTitle": "Planning", @@ -8986,7 +9164,7 @@ "kind": "section" }, { - "id": "662-zql#inspecting-query-plans", + "id": "675-zql#inspecting-query-plans", "title": "ZQL", "searchTitle": "Inspecting Query Plans", "sectionTitle": "Inspecting Query Plans", @@ -8996,7 +9174,7 @@ "kind": "section" }, { - "id": "663-zql#manually-flipping-joins", + "id": "676-zql#manually-flipping-joins", "title": "ZQL", "searchTitle": "Manually Flipping Joins", "sectionTitle": "Manually Flipping Joins", @@ -9006,7 +9184,7 @@ "kind": "section" }, { - "id": "664-zql#scalar-subqueries", + "id": "677-zql#scalar-subqueries", "title": "ZQL", "searchTitle": "Scalar Subqueries", "sectionTitle": "Scalar Subqueries", @@ -9016,7 +9194,7 @@ "kind": "section" }, { - "id": "665-zql#why-it-matters", + "id": "678-zql#why-it-matters", "title": "ZQL", "searchTitle": "Why It Matters", "sectionTitle": "Why It Matters", @@ -9026,7 +9204,7 @@ "kind": "section" }, { - "id": "666-zql#trade-offs", + "id": "679-zql#trade-offs", "title": "ZQL", "searchTitle": "Trade-offs", "sectionTitle": "Trade-offs", @@ -9036,7 +9214,7 @@ "kind": "section" }, { - "id": "667-zql#future-work", + "id": "680-zql#future-work", "title": "ZQL", "searchTitle": "Future Work", "sectionTitle": "Future Work", @@ -9045,4 +9223,4 @@ "content": "Scalar subqueries are not currently integrated with Zero's planner. You need to manually choose when to use them.", "kind": "section" } -] \ No newline at end of file +] diff --git a/components/BenchmarkComparisonChart.tsx b/components/BenchmarkComparisonChart.tsx index 74fbc1ad..e21c06f6 100644 --- a/components/BenchmarkComparisonChart.tsx +++ b/components/BenchmarkComparisonChart.tsx @@ -18,6 +18,7 @@ import { type BenchmarkDatum = { name: string; fullName?: string; + previous?: number; current: number; }; @@ -28,32 +29,53 @@ type BenchmarkComparisonChartProps = { previousLabel?: string; currentLabel?: string; precision?: number; + valuePrecision?: number; + unit?: string; + higherIsBetter?: boolean; height?: number; className?: string; }; -function formatValue(value: unknown, precision: number) { +function formatNumber(value: unknown, precision: number) { if (typeof value !== 'number' || !Number.isFinite(value)) { return String(value); } - return `${value.toLocaleString(undefined, { + return value.toLocaleString(undefined, { minimumFractionDigits: precision, maximumFractionDigits: precision, - })}x`; + }); } -function formatBenchmarkResult(point: BenchmarkDatum, precision: number) { - if (!Number.isFinite(point.current) || point.current <= 0) { - return formatValue(point.current, precision); +function formatValue(value: unknown, precision: number, unit?: string) { + return `${formatNumber(value, precision)}${unit ? ` ${unit}` : 'x'}`; +} + +function formatBenchmarkResult( + point: BenchmarkDatum, + precision: number, + higherIsBetter: boolean, +) { + const previous = point.previous ?? 1; + if ( + !Number.isFinite(previous) || + !Number.isFinite(point.current) || + previous <= 0 || + point.current <= 0 + ) { + return 'not comparable'; } - if (point.current === 1) { + if (point.current === previous) { return 'flat'; } - const result = point.current > 1 ? point.current : 1 / point.current; - return `${formatValue(result, precision)} ${point.current > 1 ? 'faster' : 'slower'}`; + const improvement = higherIsBetter + ? point.current / previous + : previous / point.current; + const faster = improvement > 1; + const result = faster ? improvement : 1 / improvement; + return `${formatValue(result, precision)} ${faster ? 'faster' : 'slower'}`; } export function BenchmarkComparisonChart({ @@ -63,17 +85,29 @@ export function BenchmarkComparisonChart({ previousLabel = 'Previous', currentLabel = 'Current', precision = 2, + valuePrecision = precision, + unit, + higherIsBetter = true, height = 300, className, }: BenchmarkComparisonChartProps) { - const format = (value: unknown) => formatValue(value, precision); - const chartData = data.map(point => ({...point, previous: 1})); + const format = (value: unknown) => formatValue(value, valuePrecision, unit); + const chartData = data.map(point => ({ + ...point, + previous: point.previous ?? 1, + })); const minChartWidth = 450; const chartHeight = Math.max(height, data.length * 54 + 76); - const currentBarFill = (point: BenchmarkDatum | undefined) => - point && point.current < 1 - ? 'hsl(var(--destructive))' - : 'hsl(var(--primary-highlight))'; + const currentBarFill = (point: BenchmarkDatum | undefined) => { + if (!point) return 'hsl(var(--primary-highlight))'; + const previous = point.previous ?? 1; + const improved = higherIsBetter + ? point.current >= previous + : point.current <= previous; + return improved + ? 'hsl(var(--primary-highlight))' + : 'hsl(var(--destructive))'; + }; const renderCurrentBar = (props: BarShapeProps) => { const {payload, ...rectangleProps} = props; const point = payload as BenchmarkDatum | undefined; @@ -94,11 +128,18 @@ export function BenchmarkComparisonChart({ return (
-
- {point.fullName ?? point.name} -
+ {point.previous !== undefined && unit && ( +
+
+ {previousLabel}: {format(point.previous)} +
+
+ {currentLabel}: {format(point.current)} +
+
+ )}
- {formatBenchmarkResult(point, precision)} + {formatBenchmarkResult(point, precision, higherIsBetter)}
); diff --git a/components/IntroductionLanding.tsx b/components/IntroductionLanding.tsx index 797266e4..4be6e618 100644 --- a/components/IntroductionLanding.tsx +++ b/components/IntroductionLanding.tsx @@ -487,7 +487,9 @@ export function IntroductionLanding({

- Zero’s initial announcement inspired us to build our own sync engine. It worked, but was a lot to maintain. We switched to Zero itself and are happily shipping again. + Zero's initial announcement inspired us to build our own sync + engine. It worked, but was a lot to maintain. We switched to + Zero itself and are happily shipping again.

diff --git a/components/search.tsx b/components/search.tsx index 6f8d44f1..2147279a 100644 --- a/components/search.tsx +++ b/components/search.tsx @@ -102,6 +102,10 @@ export default function Search() { } }, [trimmedInput]); + useEffect(() => { + setSelectedValue(searchResults[0]?.id ?? ''); + }, [searchResults]); + const handleFallbackSelect = () => { const firstResult = searchResults[0]; if (!firstResult) return; diff --git a/components/ui/table.tsx b/components/ui/table.tsx index cda0c86a..83eccb58 100644 --- a/components/ui/table.tsx +++ b/components/ui/table.tsx @@ -6,7 +6,7 @@ const Table = React.forwardRef< HTMLTableElement, React.HTMLAttributes >(({className, ...props}, ref) => ( -
+
{` -.metrics-reference ~ h3 + table { - table-layout: fixed; - width: 100%; -} - -.metrics-reference ~ h3 + table td code { word-break: break-all; } - -.metrics-reference ~ h3 + table th:nth-child(1), -.metrics-reference ~ h3 + table td:nth-child(1) { width: 175px;} -.metrics-reference ~ h3 + table th:nth-child(2), -.metrics-reference ~ h3 + table td:nth-child(2) { width: 100px; } -.metrics-reference ~ h3 + table th:nth-child(3), -.metrics-reference ~ h3 + table td:nth-child(3) { width: 65px; } -`} + + `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 -| Metric | Type | Unit | Description | -| -------- | ----- | ---- | --------------------------------------------------------- | -| `uptime` | Gauge | s | Cumulative uptime, starting from when requests are served | +| 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 | ### 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. | +| 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 | ### 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 | -| `shadow-sync-runs` | Counter | | Number of [shadow initial-sync](/docs/zero-cache-config#shadow-sync-enabled) runs. Has a `result` attribute: `success`, `error` | -| `shadow-sync-duration` | Histogram | s | Wall-clock duration of a shadow initial-sync run. Has a `result` attribute: `success`, `error` | +| 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 | ### 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 | -| `lock-wait-time` | Histogram | s | Time spent waiting to acquire the ViewSyncerService lock per operation | -| `pipeline-resets` | Counter | | Count of pipeline resets. Has a `reason` attribute: `advancement-timeout`, `scalar-subquery`, `schema-change`, `truncation`, `permissions-change` | -| `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.flush-time` | Histogram | s | Time to flush a CVR transaction | -| `cvr.rows-flushed` | Counter | | Number of changed rows flushed to a CVR | -| `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 | +| 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 | ### zero.mutation diff --git a/contents/docs/postgres-support.mdx b/contents/docs/postgres-support.mdx index 50654039..9979d612 100644 --- a/contents/docs/postgres-support.mdx +++ b/contents/docs/postgres-support.mdx @@ -21,7 +21,7 @@ Postgres has a massive feature set, and Zero supports a growing subset of it. ## Column Types -
+
@@ -156,7 +156,7 @@ Postgres has a massive feature set, and Zero supports a growing subset of it. -
Postgres Type
+ diff --git a/contents/docs/release-notes/1.8.mdx b/contents/docs/release-notes/1.8.mdx new file mode 100644 index 00000000..52e90542 --- /dev/null +++ b/contents/docs/release-notes/1.8.mdx @@ -0,0 +1,171 @@ +--- +title: Zero 1.8 +description: Observability and Reliability +--- + +## Installation + +```bash +npm install @rocicorp/zero@1.8 +``` + +You can now use `zero-cache` from GHCR: + +```bash +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:**](/docs/zero-cache-config#mutate-allowed-request-headers) `zero-cache` can forward selected WebSocket upgrade headers to custom APIs using [`ZERO_MUTATE_ALLOWED_REQUEST_HEADERS`](/docs/zero-cache-config#mutate-allowed-request-headers) and [`ZERO_QUERY_ALLOWED_REQUEST_HEADERS`](/docs/zero-cache-config#query-allowed-request-headers). ([#6144](https://github.com/rocicorp/mono/pull/6144), thanks [@tjenkinson](https://github.com/tjenkinson)!) +- [**GHCR Docker images:**](/docs/self-host#docker-images) Zero images are now published to `ghcr.io/rocicorp/zero` as well as Docker Hub. ([#6161](https://github.com/rocicorp/mono/pull/6161)) +- [**Mutator result type:**](/docs/mutators#waiting-for-results) `MutatorResult` is now exported from `@rocicorp/zero` for typing helpers that await `.client` or `.server`. ([#6223](https://github.com/rocicorp/mono/pull/6223)) +- **Operational metrics:** `zero-cache` adds metrics for [API calls and startup](/docs/otel#zeroserver), [initial sync and replication slots](/docs/otel#zeroreplication), and [Litestream backup and restore](/docs/otel#zeroreplica). ([#6203](https://github.com/rocicorp/mono/pull/6203), [#6208](https://github.com/rocicorp/mono/pull/6208), [#6191](https://github.com/rocicorp/mono/pull/6191), [#6199](https://github.com/rocicorp/mono/pull/6199), [#6210](https://github.com/rocicorp/mono/pull/6210)) +- **Stability metrics:** New [serving-lag, CVR, and WebSocket metrics](/docs/otel#zerosync) and [replication flow-control metrics](/docs/otel#zeroreplication) help diagnose delayed updates, reconnects, and backpressure. ([#6157](https://github.com/rocicorp/mono/pull/6157), [#6214](https://github.com/rocicorp/mono/pull/6214), [#6207](https://github.com/rocicorp/mono/pull/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: + +```ts +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**: + +```ts +zql.issue.related('creator').related('comments') +``` + + + +## Fixes + +- [Logical replication now reconnects when the inbound Postgres stream goes silent.](https://github.com/rocicorp/mono/pull/6047) +- [Postgres writes no longer use sockets after disconnection.](https://github.com/rocicorp/mono/pull/6193) +- [The Drizzle adapter now handles array-mode results from Drizzle 1.0 RC `prepareQuery`.](https://github.com/rocicorp/mono/pull/6154) (thanks [@typedrat](https://github.com/typedrat)!) +- [z2s now compiles queries using `start`, and SQLite fetches handle `null` start-cursor fields.](https://github.com/rocicorp/mono/pull/6189) +- [Queries no longer appear `complete` with stale or empty results after reconnect.](https://github.com/rocicorp/mono/pull/6172) +- [React Native reads now work with `op-sqlite` v17.](https://github.com/rocicorp/mono/pull/6180) +- [View-syncers no longer fail while the first backup is uploading](https://github.com/rocicorp/mono/pull/6134) or [retry before a restorable backup exists on cold start](https://github.com/rocicorp/mono/pull/6135). +- [Zero Docker images now choose the correct default sync-worker count.](https://github.com/rocicorp/mono/pull/6198) +- [Change-stream catch-up now respects flow control, preventing unbounded in-memory backlogs.](https://github.com/rocicorp/mono/pull/6186) + +## Breaking Changes + +None. diff --git a/contents/docs/release-notes/index.mdx b/contents/docs/release-notes/index.mdx index b8105ea3..9692efe0 100644 --- a/contents/docs/release-notes/index.mdx +++ b/contents/docs/release-notes/index.mdx @@ -2,6 +2,7 @@ title: Release Notes --- +- [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) - [Zero 1.5: Schema Change Improvements and Client Group Auth](/docs/release-notes/1.5) diff --git a/contents/docs/self-host.mdx b/contents/docs/self-host.mdx index 79dd3d52..49b9655a 100644 --- a/contents/docs/self-host.mdx +++ b/contents/docs/self-host.mdx @@ -25,6 +25,13 @@ You will also need to deploy a Postgres database, your frontend, and your API se Before setting up Postgres, read [Connecting to Postgres](/docs/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. diff --git a/contents/docs/zero-cache-config.mdx b/contents/docs/zero-cache-config.mdx index 386824b5..2b0f6809 100644 --- a/contents/docs/zero-cache-config.mdx +++ b/contents/docs/zero-cache-config.mdx @@ -464,7 +464,7 @@ env: `ZERO_MUTATE_API_KEY`
### Mutate Allowed Client Headers -Comma-separated list of custom request headers that zero-cache is allowed to forward to your mutate endpoint. Header names are matched case-insensitively. +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. @@ -472,6 +472,20 @@ 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](#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. @@ -554,7 +568,7 @@ env: `ZERO_QUERY_API_KEY`
### Query Allowed Client Headers -Comma-separated list of custom request headers that zero-cache is allowed to forward to your query endpoint. Header names are matched case-insensitively. +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. @@ -562,6 +576,20 @@ 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](#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. diff --git a/lib/mdx.ts b/lib/mdx.ts index 8db624b6..93e81d7f 100644 --- a/lib/mdx.ts +++ b/lib/mdx.ts @@ -21,6 +21,7 @@ import Note from '@/components/note'; import ImageLightbox from '@/components/ui/ImageLightbox'; import Video from '@/components/ui/Video'; import {Button} from '@/components/ui/button'; +import {Table} from '@/components/ui/table'; import {getDocsTocEntries} from './docs-headings'; import rehypePrettyCode from 'rehype-pretty-code'; import {getLatestNpmVersions} from './get-latest-npm-versions'; @@ -32,6 +33,8 @@ const components = { ImageLightbox, Video, Button, + Table, + table: Table, BenchmarkComparisonChart, CodeGroup, StartingPointCards, diff --git a/lib/search.ts b/lib/search.ts index 613d66fe..6fe29389 100644 --- a/lib/search.ts +++ b/lib/search.ts @@ -13,13 +13,55 @@ const SNIPPET_LENGTH = 120; const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -const normalizeTokens = (value: string) => +const tokenize = (value: string) => lunr .tokenizer(value) - .map(token => token.toString().toLowerCase()) - .flatMap(token => token.split(/[^a-z0-9]+/i)) + .map(token => + token.toString().toLowerCase().replace(/^\W+/, '').replace(/\W+$/, ''), + ) .filter(Boolean); +const splitPunctuation = (tokens: string[]) => + tokens.flatMap(token => token.split(/[^a-z0-9]+/i)).filter(Boolean); + +const normalizeText = (value: string) => + value + .toLowerCase() + .replace(/[^a-z0-9.]+/g, ' ') + .trim(); + +const urlToText = (url: string) => + normalizeText(url.replace(/^\/docs\/?/, '').replace(/\//g, ' ')); + +const versionPattern = /\b\d+\.\d+(?:\.\d+)?\b/g; +const hasVersionPattern = /\b\d+\.\d+(?:\.\d+)?\b/; + +const isReleaseQuery = (query: string) => + /\b(release|notes?|changelog|versions?|upgrade|upgrading|breaking)\b/i.test( + query, + ) || hasVersionPattern.test(query); + +const isDeprecatedQuery = (query: string) => + /\b(deprecated|legacy|old|rls|crud|synced\s*query|ad\s*-?\s*hoc)\b/i.test( + query, + ); + +const tokenMatchesText = (token: string, text: string) => { + const normalizedToken = normalizeText(token); + if (!normalizedToken) return false; + return text + .split(' ') + .some(part => part === normalizedToken || part.startsWith(normalizedToken)); +}; + +const allTokensMatchText = (tokens: string[], text: string) => + tokens.length > 0 && tokens.every(token => tokenMatchesText(token, text)); + +const isClientApiQuery = (tokens: string[]) => + tokens.some( + token => /^use[a-z0-9]{2,}/i.test(token) || /provider$/i.test(token), + ); + const findFirstMatchIndex = (content: string, terms: string[]) => { const lowerContent = content.toLowerCase(); let bestIndex = -1; @@ -107,6 +149,8 @@ export const createLunrIndex = (docs: SearchDocument[]) => { b.ref('id'); b.field('searchTitle', {boost: 14}); b.field('sectionTitle', {boost: 16}); + b.field('title', {boost: 10}); + b.field('urlTokens', {boost: 8}); b.field('content'); b.field('headings', {boost: 6}); @@ -115,6 +159,8 @@ export const createLunrIndex = (docs: SearchDocument[]) => { id: doc.id, searchTitle: doc.searchTitle?.toLowerCase() ?? '', sectionTitle: doc.sectionTitle?.toLowerCase() ?? '', + title: doc.title?.toLowerCase() ?? '', + urlTokens: urlToText(doc.url), content: doc.content?.toLowerCase() ?? '', headings: (doc.headings ?? []) .map(heading => heading.text.toLowerCase()) @@ -124,22 +170,86 @@ export const createLunrIndex = (docs: SearchDocument[]) => { }); }; -const getPhraseBoost = (doc: SearchDocument, phrase: string) => { - if (!phrase) return 0; - const normalizedPhrase = phrase.toLowerCase(); - - const sectionTitle = doc.sectionTitle?.toLowerCase() ?? ''; - const pageTitle = doc.title.toLowerCase(); +const getRankingBoost = ( + doc: SearchDocument, + query: string, + tokens: string[], +) => { + const normalizedPhrase = normalizeText(query); + if (!normalizedPhrase) return 0; + + const searchTitle = normalizeText(doc.searchTitle ?? ''); + const sectionTitle = normalizeText(doc.sectionTitle ?? ''); + const pageTitle = normalizeText(doc.title); + const urlText = urlToText(doc.url); + const titleText = normalizeText( + `${doc.title} ${doc.searchTitle ?? ''} ${doc.sectionTitle ?? ''} ${urlText}`, + ); const headingsText = (doc.headings ?? []) - .map(heading => heading.text.toLowerCase()) + .map(heading => normalizeText(heading.text)) .join(' '); - const content = doc.content.toLowerCase(); + const content = normalizeText(doc.content); + + let boost = 0; + + if (sectionTitle === normalizedPhrase) boost += 28; + else if (sectionTitle.includes(normalizedPhrase)) boost += 18; - if (sectionTitle && sectionTitle.includes(normalizedPhrase)) return 6; - if (pageTitle.includes(normalizedPhrase)) return 4; - if (headingsText.includes(normalizedPhrase)) return 3; - if (content.includes(normalizedPhrase)) return 1; - return 0; + if (searchTitle === normalizedPhrase) boost += 24; + else if (searchTitle.includes(normalizedPhrase)) boost += 14; + + if (pageTitle === normalizedPhrase) boost += 22; + else if (pageTitle.includes(normalizedPhrase)) boost += 12; + + if (titleText.includes(normalizedPhrase)) boost += 8; + if (headingsText.includes(normalizedPhrase)) boost += 4; + if (content.includes(normalizedPhrase)) boost += 1; + + const comparableTokens = tokens.filter(token => token.length > 1); + if (allTokensMatchText(comparableTokens, titleText)) boost += 12; + if (allTokensMatchText(comparableTokens, sectionTitle)) boost += 10; + if (allTokensMatchText(comparableTokens, searchTitle)) boost += 8; + if (allTokensMatchText(comparableTokens, pageTitle)) boost += 6; + + if (isClientApiQuery(tokens)) { + if (doc.url === '/docs/react') boost += 8; + else if (doc.url === '/docs/solidjs' || doc.url === '/docs/react-native') { + boost += 5; + } else if (doc.url === '/docs/queries') { + boost += 3; + } + } + + for (const version of query.match(versionPattern) ?? []) { + if (doc.url.includes(version) || doc.title.includes(version)) { + boost += 20; + } + } + + return boost; +}; + +const getRankingMultiplier = (doc: SearchDocument, query: string) => { + let multiplier = 1; + + if (doc.url.startsWith('/docs/release-notes') && !isReleaseQuery(query)) { + multiplier *= 0.2; + } + + if (doc.url.startsWith('/docs/deprecated') && !isDeprecatedQuery(query)) { + multiplier *= 0.15; + } + + if ( + !isDeprecatedQuery(query) && + normalizeText(`${doc.searchTitle} ${doc.sectionTitle ?? ''}`).includes( + 'deprecated', + ) + ) { + multiplier *= 0.2; + } + + return multiplier; }; const getFuzzyDistance = (token: string) => { @@ -148,26 +258,29 @@ const getFuzzyDistance = (token: string) => { return 2; }; +type QueryStrategy = 'exact' | 'prefix' | 'fuzzy'; + const buildQuery = ( builder: lunr.Query, tokens: string[], { presence, - includeFuzzy, + strategy, }: { presence: lunr.Query.presence; - includeFuzzy: boolean; + strategy: QueryStrategy; }, ) => { for (const token of tokens) { - builder.term(token, {boost: 8, presence}); - builder.term(token, { - boost: 3, - presence, - wildcard: lunr.Query.wildcard.TRAILING, - }); - - if (includeFuzzy) { + if (strategy === 'exact') { + builder.term(token, {boost: 8, presence}); + } else if (strategy === 'prefix') { + builder.term(token, { + boost: 4, + presence, + wildcard: lunr.Query.wildcard.TRAILING, + }); + } else { const editDistance = getFuzzyDistance(token); if (editDistance !== null) { builder.term(token, { @@ -175,6 +288,8 @@ const buildQuery = ( presence, editDistance, }); + } else { + builder.term(token, {boost: 8, presence}); } } } @@ -192,34 +307,64 @@ export const searchDocuments = ({ const sanitizedInput = query.trim(); if (!sanitizedInput) return []; - const tokens = normalizeTokens(sanitizedInput); - if (!tokens.length) return []; + const primaryTokens = tokenize(sanitizedInput); + if (!primaryTokens.length) return []; - const requiredPresence = - tokens.length > 1 - ? lunr.Query.presence.REQUIRED - : lunr.Query.presence.OPTIONAL; + const fallbackTokens = splitPunctuation(primaryTokens); + const tokenSets = [primaryTokens]; + const preservesVersionToken = primaryTokens.some(token => + hasVersionPattern.test(token), + ); + if ( + !preservesVersionToken && + fallbackTokens.length && + (fallbackTokens.length !== primaryTokens.length || + fallbackTokens.some((token, index) => token !== primaryTokens[index])) + ) { + tokenSets.push(fallbackTokens); + } - const runQuery = (config: { - presence: lunr.Query.presence; - includeFuzzy: boolean; - }) => index.query(builder => buildQuery(builder, tokens, config)); + const runQueries = (strategies: QueryStrategy[], optionalOnly = false) => { + const resultsByRef = new Map(); + let matchedTokens: string[] | null = null; + + for (const tokens of tokenSets) { + if (optionalOnly && tokens.length < 2) continue; + + const presence = optionalOnly + ? lunr.Query.presence.OPTIONAL + : tokens.length > 1 + ? lunr.Query.presence.REQUIRED + : lunr.Query.presence.OPTIONAL; + for (const strategy of strategies) { + const results = index.query(builder => + buildQuery(builder, tokens, {presence, strategy}), + ); + if (results.length && !matchedTokens) matchedTokens = tokens; + + for (const result of results) { + const existing = resultsByRef.get(result.ref); + if (!existing || result.score > existing.score) { + resultsByRef.set(result.ref, result); + } + } + } + } - let results = runQuery({presence: requiredPresence, includeFuzzy: false}); - if (!results.length) { - results = runQuery({presence: requiredPresence, includeFuzzy: true}); - } - if (!results.length && tokens.length > 1) { - results = runQuery({ - presence: lunr.Query.presence.OPTIONAL, - includeFuzzy: true, - }); - } + return resultsByRef.size && matchedTokens + ? {results: Array.from(resultsByRef.values()), tokens: matchedTokens} + : null; + }; + + const match = + runQueries(['exact', 'prefix']) ?? + runQueries(['fuzzy']) ?? + runQueries(['fuzzy'], true); + if (!match) return []; - if (!results.length) return []; + const {results, tokens} = match; const documentsById = new Map(documents.map(doc => [doc.id, doc])); - const phrase = tokens.length > 1 ? tokens.join(' ') : ''; const highlightTerms = Array.from( new Set([sanitizedInput, ...tokens].filter(Boolean)), ); @@ -229,8 +374,9 @@ export const searchDocuments = ({ const doc = documentsById.get(result.ref); if (!doc) return null; - const phraseBoost = tokens.length > 1 ? getPhraseBoost(doc, phrase) : 0; - const score = result.score + phraseBoost; + const score = + (result.score + getRankingBoost(doc, sanitizedInput, tokens)) * + getRankingMultiplier(doc, sanitizedInput); const snippet = extractSnippet(doc.content, highlightTerms); const snippetId = diff --git a/tests/search.test.ts b/tests/search.test.ts index d9774577..64656a7e 100644 --- a/tests/search.test.ts +++ b/tests/search.test.ts @@ -1,11 +1,194 @@ import path from 'node:path'; -import {describe, expect, it} from 'vitest'; +import {beforeAll, describe, expect, it} from 'vitest'; import {createLunrIndex, searchDocuments} from '@/lib/search'; import {extractDocumentsFromMDX} from '@/lib/generateSearchIndex'; +import {getAllMDXFiles} from '@/lib/get-slugs'; +import type {SearchDocument} from '@/lib/search-types'; const getDocPath = (slug: string) => path.join(process.cwd(), 'contents', 'docs', `${slug}.mdx`); +const getAllDocPaths = () => + getAllMDXFiles(path.join(process.cwd(), 'contents', 'docs')).sort(); + +const loadAllSearchDocuments = async () => + ( + await Promise.all( + getAllDocPaths().map(file => extractDocumentsFromMDX(file)), + ) + ).flat(); + +type CommonSearchCase = { + query: string; + expectedTopUrl?: string; + expectedTopComposedUrl?: string; +}; + +const commonSearchCases: CommonSearchCase[] = [ + {query: 'install zero', expectedTopComposedUrl: '/docs/install#install-zero'}, + { + query: 'npm install @rocicorp/zero', + expectedTopComposedUrl: '/docs/install#install-zero', + }, + {query: 'zero-cache-dev', expectedTopUrl: '/docs/tutorial'}, + {query: 'ZeroProvider', expectedTopUrl: '/docs/react'}, + {query: 'useQuery', expectedTopUrl: '/docs/react'}, + {query: 'useSuspenseQuery', expectedTopComposedUrl: '/docs/react#suspense'}, + {query: 'react native', expectedTopUrl: '/docs/react-native'}, + {query: 'createSchema', expectedTopUrl: '/docs/schema'}, + { + query: 'relationships', + expectedTopComposedUrl: '/docs/schema#relationships', + }, + { + query: 'schema changes', + expectedTopComposedUrl: '/docs/schema#schema-changes', + }, + { + query: 'postgres column types', + expectedTopComposedUrl: '/docs/postgres-support#column-types', + }, + {query: 'defineQuery', expectedTopUrl: '/docs/queries'}, + { + query: 'whereExists', + expectedTopUrl: '/docs/zql', + }, + {query: 'ttl', expectedTopComposedUrl: '/docs/queries#ttls'}, + {query: 'defineMutator', expectedTopUrl: '/docs/mutators'}, + {query: 'write data', expectedTopComposedUrl: '/docs/mutators#writing-data'}, + { + query: 'server mutators', + expectedTopComposedUrl: '/docs/mutators#server-setup', + }, + {query: 'auth', expectedTopUrl: '/docs/auth'}, + { + query: 'cookie authentication', + expectedTopComposedUrl: '/docs/auth#cookies', + }, + {query: 'token auth', expectedTopComposedUrl: '/docs/auth#tokens'}, + { + query: 'read permissions', + expectedTopComposedUrl: '/docs/auth#read-permissions', + }, + { + query: 'write permissions', + expectedTopComposedUrl: '/docs/auth#write-permissions', + }, + {query: 'zero-cache config', expectedTopUrl: '/docs/zero-cache-config'}, + { + query: 'ZERO_UPSTREAM_DB', + expectedTopComposedUrl: '/docs/zero-cache-config#upstream-db', + }, + { + query: 'admin password', + expectedTopComposedUrl: '/docs/zero-cache-config#admin-password', + }, + {query: 'self host', expectedTopUrl: '/docs/self-host'}, + {query: 'docker compose', expectedTopUrl: '/docs/self-host'}, + { + query: 'ghcr docker', + expectedTopComposedUrl: '/docs/self-host#docker-images', + }, + { + query: 'connecting to postgres', + expectedTopUrl: '/docs/connecting-to-postgres', + }, + { + query: 'wal level', + expectedTopComposedUrl: '/docs/connecting-to-postgres#wal-level', + }, + { + query: 'logical replication', + expectedTopComposedUrl: '/docs/connecting-to-postgres#logical-replication', + }, + { + query: 'otel metrics', + expectedTopComposedUrl: '/docs/otel#metrics-reference', + }, + { + query: 'inspector analyze', + expectedTopComposedUrl: '/docs/debug/inspector#analyzing-queries', + }, + {query: 'analyze query cli', expectedTopUrl: '/docs/debug/analyze-query-cli'}, + {query: 'release notes 1.8', expectedTopUrl: '/docs/release-notes/1.8'}, + {query: 'instal', expectedTopUrl: '/docs/install'}, + {query: 'instal zero', expectedTopComposedUrl: '/docs/install#install-zero'}, + {query: 'cache dev', expectedTopUrl: '/docs/tutorial'}, + {query: 'zeroprovider', expectedTopUrl: '/docs/react'}, + {query: 'usequer', expectedTopUrl: '/docs/react'}, + {query: 'suspense query', expectedTopUrl: '/docs/react'}, + {query: 'schema gen', expectedTopUrl: '/docs/schema'}, + {query: 'drizzle schema', expectedTopUrl: '/docs/schema'}, + { + query: 'many many', + expectedTopComposedUrl: '/docs/schema#many-to-many-relationships', + }, + {query: 'relations', expectedTopComposedUrl: '/docs/schema#relationships'}, + { + query: 'where exist', + expectedTopUrl: '/docs/zql', + }, + {query: 'mutator', expectedTopUrl: '/docs/mutators'}, + {query: 'server mut', expectedTopUrl: '/docs/mutators'}, + {query: 'cookies', expectedTopComposedUrl: '/docs/auth#cookies'}, + {query: 'jwt', expectedTopUrl: '/docs/zero-cache-config'}, + { + query: 'permiss', + expectedTopComposedUrl: '/docs/mutators#permissions', + }, + { + query: 'read permiss', + expectedTopComposedUrl: '/docs/auth#read-permissions', + }, + { + query: 'write permiss', + expectedTopComposedUrl: '/docs/auth#write-permissions', + }, + {query: 'env vars', expectedTopUrl: '/docs/debug/analyze-query-cli'}, + { + query: 'upstream db', + expectedTopComposedUrl: '/docs/zero-cache-config#upstream-db', + }, + { + query: 'ZERO_UPSTREAM', + expectedTopComposedUrl: '/docs/zero-cache-config#upstream-db', + }, + { + query: 'admin pass', + expectedTopComposedUrl: '/docs/zero-cache-config#admin-password', + }, + {query: 'ghcr', expectedTopComposedUrl: '/docs/self-host#docker-images'}, + {query: 'docker', expectedTopUrl: '/docs/self-host'}, + { + query: 'wal', + expectedTopComposedUrl: '/docs/connecting-to-postgres#wal-level', + }, + { + query: 'wal_level', + expectedTopComposedUrl: '/docs/connecting-to-postgres#wal-level', + }, + { + query: 'supabase', + expectedTopComposedUrl: '/docs/connecting-to-postgres#supabase', + }, + { + query: 'neon', + expectedTopComposedUrl: '/docs/connecting-to-postgres#neon', + }, + {query: 'otel', expectedTopUrl: '/docs/otel'}, + {query: 'metrics', expectedTopComposedUrl: '/docs/otel#metrics-reference'}, + {query: 'slow quer', expectedTopUrl: '/docs/debug/slow-queries'}, + { + query: 'query plans', + expectedTopComposedUrl: '/docs/zql#inspecting-query-plans', + }, + { + query: 'replication reset', + expectedTopComposedUrl: '/docs/debug/replication#resetting', + }, + {query: '1.8', expectedTopUrl: '/docs/release-notes/1.8'}, +]; + describe('search index', () => { it('creates section records for headings', async () => { const docs = await extractDocumentsFromMDX(getDocPath('zql')); @@ -41,4 +224,61 @@ describe('search index', () => { expect(results.length).toBeGreaterThan(0); expect(results[0]?.composedUrl.startsWith('/docs/install')).toBe(true); }); + + it('preserves dotted version numbers', () => { + const docs: SearchDocument[] = [ + { + id: 'release-1.8', + title: 'Zero 1.8', + searchTitle: 'Zero 1.8', + content: 'Install Zero 1.8 for the latest improvements.', + url: '/docs/release-notes/1.8', + kind: 'page', + }, + { + id: 'zql', + title: 'ZQL', + searchTitle: 'ZQL', + content: 'Call limit(100), subtract 1, and encode as UTF-8.', + url: '/docs/zql', + kind: 'page', + }, + ]; + const index = createLunrIndex(docs); + const results = searchDocuments({index, documents: docs, query: '1.8'}); + + expect(results[0]?.url).toBe('/docs/release-notes/1.8'); + expect(results.some(result => result.id === 'zql')).toBe(false); + expect(results[0]?.snippet).toContain('1.8'); + expect(results[0]?.snippet).not.toContain('100'); + }); + + describe('common documentation searches', () => { + let docs: SearchDocument[]; + let index: ReturnType; + + beforeAll(async () => { + docs = await loadAllSearchDocuments(); + index = createLunrIndex(docs); + }, 30_000); + + it.each( + commonSearchCases.map(testCase => [testCase.query, testCase] as const), + )('returns the expected top result for %s', (_query, testCase) => { + const results = searchDocuments({ + index, + documents: docs, + query: testCase.query, + }); + const topResult = results[0]; + + expect(topResult, `Expected results for ${testCase.query}`).toBeTruthy(); + if (testCase.expectedTopComposedUrl) { + expect(topResult?.composedUrl).toBe(testCase.expectedTopComposedUrl); + } + if (testCase.expectedTopUrl) { + expect(topResult?.url).toBe(testCase.expectedTopUrl); + } + }); + }); });