# Rindle — Build a synced app > A synced application: schema, live queries, optimistic writes, authorization, and deployment. Rindle uses incremental view maintenance (IVM) to update registered query results after writes. Embed SQLite and live queries with the Rust `rindle-replica` crate, or supply changes directly to the raw `rindle` engine. Browser choices include a standalone WASM store, flat remote results, normalized sync without prediction, and the integrated optimistic `createRindleClient`. These clients share query APIs but have different protocols and lifecycles. Rindle SQL provides ordinary request/response SQL. PostgreSQL can remain the write authority through a preview source integration. Start with [Onboarding](https://rindle.sh/docs/overview.md), then [Getting started](https://rindle.sh/docs/getting-started.md) to select a setup. Use [Guides](https://rindle.sh/docs/guides.md) for tasks, [Reference](https://rindle.sh/docs/api.md) for APIs and packages, and the [coding-agent guide](https://rindle.sh/docs/for-agents.md) for task-specific reading. Fetch the relevant individual pages before choosing a full bundle. Each documentation page is available at its canonical URL plus `.md`. This focused bundle contains the "Build a synced app" documentation in reading order. The [complete index](https://rindle.sh/llms.txt) includes other ways to use Rindle. --- # Welcome to Rindle Maintain query results as data changes. Use a local engine, embed a database, stream server results, or build an optimistic synced app. Rindle keeps query results up to date as your data changes. You define a query once, and the engine maintains a live view of its result. An issue list is one example. New issues appear, closed issues leave, and changes to priority update the order. Your query describes the result. Rindle computes the changes that keep that result correct. You choose how much of Rindle your application uses. A local store can maintain views over rows you supply. An embedded database can capture SQL writes. A browser can receive server results, keep local queryable rows, or also predict writes. These choices share query concepts, but they have different storage, transport, and write APIs. Start with the common idea here. Then [choose your first project](https://rindle.sh/docs/getting-started). ## Why Rindle? - **Keep views current.** Rindle maintains query results after writes. You do not need a separate refresh schedule or invalidation rule for each view. - **Describe the data you need.** Typed queries express filters, ordering, limits, and relationships. Your application subscribes to the resulting rows. - **Make local interactions responsive.** In a synced app, the browser runs the engine over local data. Optimistic writes update affected views before the server responds. The technique behind live views is **incremental view maintenance** (IVM). The engine uses each write to update affected results. The work depends on the query, indexes, and affected rows. A change can also affect related rows. The correctness rule is the same everywhere: **view-after-write == fresh-query**. After the engine applies a write, the maintained result equals a fresh query over the same data. ## Rows in, view out This example runs the engine in memory through `@rindle/wasm`. It needs a browser project with the package installed. The [browser guide](https://rindle.sh/docs/wasm-client?path=engine) covers installation and initialization. ```ts import { table, string, number, boolean, createSchema, createWasmStore, } from "@rindle/wasm"; const issue = table("issue") .columns({ id: number(), title: string(), closed: boolean() }) .primaryKey("id"); const store = await createWasmStore(createSchema({ tables: [issue] })); const view = store.query.issue .where.closed(false) .orderBy("id", "asc") .materialize(); const unsubscribe = view.subscribe((rows) => console.log(rows)); // First result: [] const first = { id: 1, title: "Try a live query", closed: false }; await store.write((tx) => tx.add("issue", first)); // Result: [{ id: 1, title: "Try a live query", closed: false }] await store.write((tx) => tx.edit("issue", first, { ...first, closed: true })); // Result: [] unsubscribe(); view.destroy(); ``` `materialize()` creates the live view. `subscribe()` delivers its current rows, then the updated rows after each write that changes the result. The final two calls release the subscription and view. This standalone store has no persistence or sync. Its TypeScript schema defines the local tables. For a database-backed app, SQL defines the tables, and Rindle generates the TypeScript schema from them. ## How the pieces fit The engine maintains views over rows. The surrounding packages determine where those rows live, how writes reach them, and how results reach your application. | Capability | What it adds | Start here | | --- | --- | --- | | Local live views | Queries over rows your application supplies | [Standalone WASM](https://rindle.sh/docs/wasm-client) or [raw Rust engine](https://rindle.sh/docs/quickstart) | | Embedded SQLite database | Ordinary SQL writes, automatic change capture, and live query events inside your process | [Rust `rindle-replica`](https://rindle.sh/docs/replica-and-views), [Node addon](https://rindle.sh/docs/backends) | | Server result streams | Query results delivered to a browser without a local WASM engine | [Remote client and protocol requirements](https://rindle.sh/docs/browser-clients) | | Local queries over server data | Normalized row subscriptions feeding a browser engine, with optional optimistic writes | [Browser client choices](https://rindle.sh/docs/browser-clients) | | Rindle SQL | SQL requests and transactions over HTTP, with no browser client required | [SQL client](https://rindle.sh/docs/sql-client) | | Server read models | Live results retained between requests, including queries with no subscribers | [Pinned queries](https://rindle.sh/docs/pinned-queries) | | Complete optimistic sync | Query leases, local prediction, mutation delivery, and reconciliation with authoritative data | [`createRindleClient`](https://rindle.sh/docs/client), [app scaffold](https://rindle.sh/docs/create-rindle) | | UI integrations | Component subscriptions, route preloads, and server rendering | [Fragments](https://rindle.sh/docs/fragments), [TanStack Start](https://rindle.sh/docs/tanstack), [SSR](https://rindle.sh/docs/ssr) | These capabilities can compose. A service can write SQL while a browser subscribes to a live query over the same tables. A server can read a pinned result without a browser. The [client chooser](https://rindle.sh/docs/browser-clients) explains which transports work together; a shared query API does not make their wire protocols interchangeable. PostgreSQL can remain the source of truth through a separate [preview integration](https://rindle.sh/docs/postgres-source). Rindle SQL and this integration have different setup requirements and limitations. ### SQL, queries, and sync SQL defines database tables and performs database reads and writes. A **live query** uses the supported query builder to describe a maintained result. An ordinary SQL `SELECT` returns rows for that request. It does not create a subscription. In a synced app, a **named query** identifies the data a client requests. Your API server resolves that name and its arguments under the authenticated user. A **mutator** describes a write. The browser predicts its effect locally, and the server applies it to authoritative data. The browser holds the data delivered by its subscriptions. It can query those local rows while disconnected, but cannot fetch missing rows until it reconnects. Optimistic results can change after server confirmation or rejection. Framework adapters build on this model. TanStack Start and SSR are optional integrations. A standalone browser store needs neither a server nor a framework. ### Packages and licensing The Rust manifests for the engine, SQLite backend, replica runtime, daemon, and replicator declare Apache-2.0. These crates are not published to crates.io. The [crate map](https://rindle.sh/docs/crates) lists source dependencies, bindings, and package availability. Hosted service plans are separate from the library APIs. ## Learn enough to build 1. **Decide whether it fits.** Read [Is Rindle for you?](https://rindle.sh/docs/compare) for the tradeoffs and supported workloads. 2. **Run one example.** [Getting started](https://rindle.sh/docs/getting-started) lists the setup and reading sequence for each kind of application. 3. **Build on your example.** [Guides](https://rindle.sh/docs/guides) cover queries, writes, UI integration, and deployment. 4. **Look up an API.** [Reference](https://rindle.sh/docs/api) maps packages, query shapes, and configuration options to their documentation. The header separates **Onboarding**, **Guides**, and **Reference**. Each section has its own sidebar. The optional integration filter narrows that section to Engine & SQL or Synced apps. For a coding assistant, start with [Rindle for coding agents](https://rindle.sh/docs/for-agents). Every page is also available as Markdown from the same source. --- [View this page on Rindle](https://rindle.sh/docs/overview) --- # Is Rindle for you? Decide whether Rindle fits your application: what live queries replace, where they help, and which limits matter before you start. Rindle fits applications that need the same queries to stay current through many changes. Common examples include issue trackers, collaborative tools, live dashboards, and derived read models. The engine maintains query results from changes to the underlying rows. The synced-app packages add local reads, optimistic writes, and reconciliation with a server that controls the authoritative data. ## What it replaces A live application often combines polling or WebSockets with cached results, invalidation rules, optimistic updates, and rollback logic. Every new query or mutation adds another place to keep those rules consistent. With Rindle, a materialized query stays current as its source data changes. The engine derives each view from those rows. You subscribe to the result instead of defining a refresh rule for that view. In a synced app, named mutators describe the writes. The client applies them optimistically, then reruns pending mutators against authoritative updates. Rejected writes disappear from the local result. Your application still handles authorization, validation, and messages that explain rejected writes to users. These responsibilities differ by integration. The embedded engine maintains views over the rows you supply. The [synced client](https://rindle.sh/docs/client?path=app) and server packages provide the sync and optimistic-write workflow. ## Who it's for - **Application teams** that need live lists, related records, and responsive writes across users. Start with the [app scaffold](https://rindle.sh/docs/create-rindle?path=app). - **Teams with derived read models** that need fresh results after source changes. Start with [replica & views](https://rindle.sh/docs/replica-and-views?path=engine). - **Browser application developers** that need reactive queries over local data. Start with [the browser engine](https://rindle.sh/docs/wasm-client?path=engine). - **Rust embedders** that need SQLite storage with live queries in their own process. Start with [`rindle-replica`](https://rindle.sh/docs/replica-and-views?path=engine). Use the [raw engine](https://rindle.sh/docs/quickstart?path=engine) when your application supplies row changes directly. - **Browser integrations** that need server results with optional local execution or optimistic writes. Start with the [client chooser](https://rindle.sh/docs/browser-clients). ## How it compares The useful comparison is the responsibility you want Rindle to take over. The choice depends on your data and application. | Approach | What it provides | What to consider | | --- | --- | --- | | Fetch requests and a client cache | Explicit requests, stored responses, and refresh controls | A practical fit for occasional reads. Frequent related updates require invalidation rules. | | Custom WebSocket sync | A transport for events or data | You define how events update each query result and reconcile optimistic writes. | | Raw Rindle engine | Live views over the rows your application supplies | You manage data ingestion, persistence, and any network transport. | | Embedded `rindle-replica` | SQLite storage, SQL write capture, and live query events | Your process owns the database lifecycle and uses the controlled writer. | | A synced Rindle app | Local queries, synchronized data, and optimistic reconciliation | You define authorized queries and deterministic mutators, and deploy the application tiers. | | An analytical database | Queries over large datasets and broad analytical workloads | A better fit for large scans and ad-hoc analytics than an engine embedded in a browser. | The Rust manifests for the engine, SQLite backend, replica runtime, daemon, and replicator declare Apache-2.0. The [crate and package map](https://rindle.sh/docs/crates) describes their APIs and distribution status. [Deployment options](https://rindle.sh/docs/deploy?path=app) describe the available server arrangements. ## When Rindle is *not* the answer - **Your data rarely changes.** A fetch request can be enough for a static page or occasional report. A maintained query adds state that such workloads do not need. - **You need unrestricted SQL for live queries.** Rindle maintains a defined set of query shapes. Its typed query builder does not turn every SQL statement into a live view. The [query reference](https://rindle.sh/docs/supported-queries-ts) lists the supported shapes and restrictions. - **You need general offline conflict merging.** The synced write model uses a server as the authority. Pending local writes rebase against that server's result. Rindle is not a general CRDT merge engine. - **Your workload requires more writes than one logical master can sustain.** A replicated deployment orders writes through one master. Read followers increase read capacity. They do not distribute writes across multiple masters. - **You need ad-hoc analytics over large, cold datasets.** Rindle maintains registered queries. A warehouse or analytical database is a better starting point for large scans and broad aggregation. Incremental maintenance also has a cost. Initial queries must read their starting data. Later writes can affect many rows through relationships. Actual work depends on the query, indexes, result size, and changes. The [performance guide](https://rindle.sh/docs/performance?path=engine) explains the measured workloads. ## Next steps For an in-process experiment, start with [reactive queries in the browser](https://rindle.sh/docs/wasm-client?path=engine). For an application shared across users, start with the [app scaffold](https://rindle.sh/docs/create-rindle?path=app). [Getting started](https://rindle.sh/docs/getting-started) also covers SQL services, server read models, embedded runtimes, and PostgreSQL integration. --- [View this page on Rindle](https://rindle.sh/docs/compare) --- # Getting started Choose a first project, see what it requires, and follow a short sequence to a working result. Start with the [welcome page](https://rindle.sh/docs/overview) for the common model: rows, a query, and a result that stays current. Then choose the outcome you need. Each sequence here is independent. You do not need to finish an engine tutorial before building a synced app. ## Choose a starting point | I want to… | Start with | What I need | | --- | --- | --- | | Choose a browser client | [Browser live views](#browser-live-views) | Decide between local data, streamed results, normalized sync, and optimistic writes | | Embed a SQLite database with live queries | [Embedded and server views](#embedded-and-server-views) | Rust and a C toolchain, or a repository build of the Node addon | | Feed changes into the raw Rust engine | [Engine primitives](#engine-primitives) | Rust, a data source, and application-owned ingestion | | Use Rindle as a SQL database | [SQL service](#sql-service) | A Rindle deployment and a server environment with `fetch` | | Keep server results warm between requests | [Server read models](#server-read-models) | A Rindle data tier with live-query support | | Share data between users and devices | [Synced application](#synced-application) | Node, a browser, and a local Rindle data tier | | Keep PostgreSQL as my authority | [PostgreSQL integration](#postgresql-integration) | A compatible Postgres deployment and the preview gateway | ## Browser live views Start with the [browser client chooser](https://rindle.sh/docs/browser-clients). The package name `@rindle/client` identifies the shared schema, query, store, and view APIs. It does not choose a database or connect to a server by itself. For client-only tools or a first local experiment: 1. [Create a browser store](https://rindle.sh/docs/wasm-client) with a TypeScript schema and some rows. 2. [Build a query](https://rindle.sh/docs/supported-queries-ts), materialize it, and subscribe to its result. 3. Write a row and observe the updated result. The standalone `@rindle/wasm` store is in memory. Your application supplies its rows and manages any persistence or network transport. This setup does not require SQL migrations, named queries, mutators, or an API server. For server data, choose between a remote result stream, normalized rows with local queries, and optimistic sync. The chooser documents their transport requirements. `createRindleClient` supplies the complete lease and mutation lifecycle for the standard Rindle API-server and daemon deployment. The [local-only tables](https://rindle.sh/docs/local-only-tables) guide adds device data to a client that also handles synced data. It is separate from a standalone WASM store. ## Embedded and server views Use [`rindle-replica`](https://rindle.sh/docs/replica-and-views) when your Rust application needs a SQLite database and live queries in the same process. You write ordinary SQL through its controlled writer. The runtime captures changes and updates registered queries. No daemon, browser client, or network transport is required. 1. Run the [embedded replica example](https://rindle.sh/docs/replica-and-views). 2. Start with `Db` for one owner thread, or choose `Cluster` for a worker pool. 3. Consume the initial result and subsequent [change events](https://rindle.sh/docs/change-model). 4. Keep writes on the runtime's controlled connection so it can capture them. For Node, the [native addon walkthrough](https://rindle.sh/docs/backends#node-live-views) exposes the runtime through the TypeScript store API. It requires a repository build; the addon is not part of the public npm release workflow. ## Engine primitives Use the `rindle` crate directly when your application owns the data source and can supply each row change. This is useful for custom ingestion, in-memory data, or an integration below the database runtime. 1. Read [How it works](https://rindle.sh/docs/how-it-works) for sources, the graph, and maintained views. 2. Run the [raw engine quickstart](https://rindle.sh/docs/quickstart). 3. Choose supported [Rust query shapes](https://rindle.sh/docs/supported-queries). The core graph does not observe unrelated SQL writes or provide a network service. The [crate map](https://rindle.sh/docs/crates) separates these primitives from the embedded runtime. ## SQL service Use this sequence for a service, script, or ORM that sends ordinary SQL requests. 1. Start a local deployment with the [CLI](https://rindle.sh/docs/rindle-cli), or provision a [Cloud database](https://rindle.sh/docs/cloud-quickstart). 2. Connect the [SQL client](https://rindle.sh/docs/sql-client) with the deployment URL and server credential. 3. Apply [SQL migrations](https://rindle.sh/docs/schema) and run reads or transactions. SQL-only use does not require a browser client, TypeScript query schema, or optimistic writes. Generate a TypeScript schema when you add typed live queries. Keep database credentials on the server. ## Server read models Use this sequence for a leaderboard, public page, or API response that reads a maintained result. The consumer can make ordinary requests without subscribing. 1. Connect to a [data tier](https://rindle.sh/docs/daemon) with live-query support. 2. Define a named query and [pin its result](https://rindle.sh/docs/pinned-queries). 3. Read the result through the API server for each request. Pins keep results warm with no subscribers. The engine still processes writes that affect them. Initial materialization and result serialization also have a cost. No browser sync, optimistic mutator, or UI framework is required. ## Synced application Use this sequence for an application with shared data and responsive local writes. 1. [Scaffold an app](https://rindle.sh/docs/create-rindle) and run it locally. 2. Read the [three-tier architecture](https://rindle.sh/docs/architecture) to understand each component. 3. Change the [schema](https://rindle.sh/docs/schema) and define [queries and fragments](https://rindle.sh/docs/fragments). 4. Add [mutators](https://rindle.sh/docs/mutators) and [authorize reads and writes](https://rindle.sh/docs/authorization). 5. Read the [optimistic client](https://rindle.sh/docs/client) guide for synchronization and resource cleanup. The scaffold uses TanStack Start and includes server rendering. For another framework or an existing project, use the [manual quickstart](https://rindle.sh/docs/synced-app-quickstart). You can add [TanStack integration](https://rindle.sh/docs/tanstack), [preloads](https://rindle.sh/docs/preloads), and [SSR](https://rindle.sh/docs/ssr) independently as needed. Then choose a task guide: [pagination](https://rindle.sh/docs/pagination), [local-only tables](https://rindle.sh/docs/local-only-tables), [rejected writes](https://rindle.sh/docs/rejected-writes), or [testing](https://rindle.sh/docs/testing). Use [deployment](https://rindle.sh/docs/deploy) when the application is ready to run outside development. ## PostgreSQL integration The [PostgreSQL source](https://rindle.sh/docs/postgres-source) is a preview integration. Read its status, supported types, DDL restrictions, and recovery requirements before planning a deployment. Postgres remains authoritative. A gateway captures its changes, and Rindle followers maintain live queries over those changes. Your application can keep its existing Postgres writers or use the documented mutator backend. This integration has a separate setup from Rindle SQL. It is not a connection string change in the standard scaffold. ## Find the next page Continue with [Guides](https://rindle.sh/docs/guides) for a task, or [Reference](https://rindle.sh/docs/api) for an API, package, or configuration option. The header keeps both sections available from every article. The focused [engine and SQL](https://rindle.sh/docs/engine) and [synced-app](https://rindle.sh/docs/app) lists collect related pages for a longer read. For an LLM-assisted project, give your assistant [Rindle for coding agents](https://rindle.sh/docs/for-agents) and the pages for your chosen integration. --- [View this page on Rindle](https://rindle.sh/docs/getting-started) --- # Rindle for coding agents Choose the Rindle integration that fits the task, fetch the relevant documentation, and keep runtime boundaries clear. This page helps coding agents build with Rindle. The same [introduction](https://rindle.sh/docs/overview) and [getting started guide](https://rindle.sh/docs/getting-started) serve human readers and agents. Rindle maintains query results as data changes. The engine is the shared foundation. A database runtime captures writes, and a synced app adds authorization, subscriptions, and optimistic browser writes. The [Agents on live data](https://rindle.sh/docs/agents) guide covers a different task: an agent inside your application that reacts to query changes. ## Start with the task Before choosing packages, identify where the data lives and which process can write it. Keep the existing application framework unless the task requires a new project. | Task | Read first | Then read | | --- | --- | --- | | Maintain views in one browser tab | [Browser engine](https://rindle.sh/docs/wasm-client) | [TypeScript queries](https://rindle.sh/docs/supported-queries-ts), [change model](https://rindle.sh/docs/change-model) | | Choose remote, normalized, or optimistic browser data | [Browser client chooser](https://rindle.sh/docs/browser-clients) | [Backends](https://rindle.sh/docs/backends), [optimistic client](https://rindle.sh/docs/client) | | Embed SQLite with live queries in Rust | [Replica runtime](https://rindle.sh/docs/replica-and-views) | [Rust queries](https://rindle.sh/docs/supported-queries), [change model](https://rindle.sh/docs/change-model) | | Supply row changes to the raw Rust engine | [Rust quickstart](https://rindle.sh/docs/quickstart) | [How it works](https://rindle.sh/docs/how-it-works), [change model](https://rindle.sh/docs/change-model) | | Maintain live views over SQLite in Node | [Backends](https://rindle.sh/docs/backends) | [Replica runtime](https://rindle.sh/docs/replica-and-views), [schema](https://rindle.sh/docs/schema) | | Build a synced application | [App scaffold](https://rindle.sh/docs/create-rindle) | [Architecture](https://rindle.sh/docs/architecture), [schema](https://rindle.sh/docs/schema), [client](https://rindle.sh/docs/client), [mutators](https://rindle.sh/docs/mutators) | | Add sync to an existing application | [Manual app setup](https://rindle.sh/docs/synced-app-quickstart) | [API server](https://rindle.sh/docs/api-server), [authorization](https://rindle.sh/docs/authorization) | | Run ordinary SQL from a server or script | [Rindle SQL](https://rindle.sh/docs/sql-client) | [SQL CLI](https://rindle.sh/docs/rindle-cli), [schema and migrations](https://rindle.sh/docs/schema) | | Keep PostgreSQL as the write authority | [Postgres source](https://rindle.sh/docs/postgres-source) | [API server](https://rindle.sh/docs/api-server), [mutators](https://rindle.sh/docs/mutators) | | Keep a server query warm between requests | [Pinned queries](https://rindle.sh/docs/pinned-queries) | [API server](https://rindle.sh/docs/api-server) | | Add TanStack Start, route preloads, or server rendering | [TanStack Start](https://rindle.sh/docs/tanstack) | [Preloads](https://rindle.sh/docs/preloads), [server rendering](https://rindle.sh/docs/ssr) | | Add local state to a synced app | [Local-only tables](https://rindle.sh/docs/local-only-tables) | [Persist local tables](https://rindle.sh/docs/persisting-local-tables) | The Postgres source is a preview. Its page states the supported query operations, deployment limits, and open release gates. ## Fetch only the relevant documentation The [documentation index](https://rindle.sh/llms.txt) lists pages with descriptions. Every docs page has a Markdown version at `/docs/.md`. For example, [/docs/client.md](https://rindle.sh/docs/client.md) contains the browser client guide without the site navigation. For tasks that need many related pages, these bundles are available: - [Engine and runtime documentation](https://rindle.sh/llms-engine.txt). - [Synced-app documentation](https://rindle.sh/llms-app.txt). - [The full site](https://rindle.sh/llms-full.txt), including product pages and blog posts. The site groups documentation into [Onboarding](https://rindle.sh/docs/overview), [Guides](https://rindle.sh/docs/guides), and [Reference](https://rindle.sh/docs/api). Each section covers multiple integrations. The focused bundles are reading aids. The [API map](https://rindle.sh/docs/api) covers the full surface, and individual pages state each feature's requirements. 1. Read the introduction and the task's first guide. 2. Read the API map to identify the packages involved. 3. Fetch the query, mutation, or runtime reference for the code you will change. 4. Read the linked limits before selecting a topology or query shape. 5. Use the installed package version and its types to resolve an API mismatch. ## Keep these distinctions clear **A live query differs from a SQL request.** A registered query stays current within the engine's supported query shapes. `@rindle/sql-client` runs SQL and returns a response. A SQL expression does not automatically become a live query. **The browser has several client compositions.** `@rindle/client` supplies shared APIs. `createWasmStore` runs locally. `createRemoteStore` receives flat query results without WASM and needs a compatible server. `createNormalizedStore` maintains local queries over server rows. `createRindleClient` supplies the standard daemon integration with leases, reconnect handling, and optimistic writes. Use the chooser before selecting a transport. **The embedded database differs from the raw engine.** `rindle-replica` captures ordinary SQL writes and manages query workers. The core `rindle` graph consumes explicit changes. A `Db`, graph, or `Cluster` coordinator belongs to one thread. A `Cluster` owns independent worker graphs and sends events through a channel that must be drained. Cluster changes can precede commit; stage them until worker progress confirms the transaction. A `Db` query remains registered until `Query::destroy`, even if its handle is dropped. **The schema source depends on the store.** A standalone in-memory store defines its tables in TypeScript. A database-backed application defines tables through SQL migrations and generates its TypeScript schema. Edit migrations and the application's schema extensions. Do not edit `schema.gen.ts`. **The server authorizes queries and writes.** A synced browser sends named queries and their arguments to the API authority. It does not receive the database token. Server-rendered reads use the same authorization rules as browser reads. **Optimistic writes are predictions.** An isomorphic mutator runs locally and on the server. Its local execution must support replay. Server-only operations and external side effects belong in the documented server hooks. **Persistence has several scopes.** Database persistence, local-table persistence, and pinned server queries have separate lifecycles. A warm query does not imply durable browser storage or offline access to every row. ## Check the result For a live query, compare the maintained result with a fresh query after the same writes. This is Rindle's core contract: **view-after-write == fresh-query**. For a synced application, cover local prediction, server confirmation, rejected writes, and a second client's updates. The [testing guide](https://rindle.sh/docs/testing) provides the corresponding harnesses. For code inside the Rindle repository, read its `AGENTS.md` and `CLAUDE.md`. Those files describe engine development and repository checks. This guide describes applications that use Rindle. --- [View this page on Rindle](https://rindle.sh/docs/for-agents) --- # Choose a browser client Compare the standalone wasm store, remote result client, normalized sync, and optimistic app client. Rindle has several browser clients. Choose based on where your data lives and whether the browser predicts writes. React, TanStack, and server rendering are separate integration choices. All TypeScript clients use the schema, query builder, `Store`, and view types from `@rindle/client`. That package is the shared core. Installing it alone does not create a database or connect to a server. ## Choose by behavior | You need | Entry point | Browser data | Write behavior | | --- | --- | --- | --- | | Live queries over data owned by one browser tab | `createWasmStore` from `@rindle/wasm` | In-memory tables and local query results | Applies local row changes immediately | | Server-maintained results through a custom flat protocol | `createRemoteStore` from `@rindle/remote` | Materialized query results; no local wasm database | Forwards raw writes; no prediction | | Synced rows with local queries, without optimistic writes | `createNormalizedStore` from `@rindle/normalized` plus a `NormalizedSource` | A local wasm database populated by retained server queries | Forwards writes through the source; no prediction | | A full synced app with named mutators | `createRindleClient` from `@rindle/optimistic` | Synced rows, local queries, and pending predictions | Predicts named writes, sends them to the API, then reconciles | | A custom optimistic transport integration | `createOptimisticStore` from `@rindle/optimistic` plus an `OptimisticSource` | The same local prediction and rebase engine | You supply the authoritative stream and mutation delivery | For a browser-only application, start with [Reactive queries in the browser](https://rindle.sh/docs/wasm-client). For the standard Rindle API server and data tier, start with [`create-rindle`](https://rindle.sh/docs/create-rindle) or [manual synced-app setup](https://rindle.sh/docs/synced-app-quickstart). The two middle choices are lower-level compositions. Their transport protocol and lifecycle must match your server. A WebSocket URL alone is not enough to connect every client to every Rindle deployment. ## Local engine: no server `createWasmStore(schema)` creates an independent in-memory database. Add rows with `store.write`, materialize queries, and subscribe to their current results. All data comes from your application. It has no built-in network sync, persistence, or optimistic queue. An immediate local write is the final write to this database; there is no server verdict to wait for. See the [standalone walkthrough](https://rindle.sh/docs/wasm-client). ## Remote results: no local engine or prediction `createRemoteStore(schema, transport)` uses the **flat** result protocol. The server sends changes to a query result, including nested results. The browser applies them to an `ArrayView`; it does not store shared base tables or run wasm. This can suit a thin client when you control the server protocol. Each remote query needs a registered name and arguments. An arbitrary local builder does not become a server query automatically. The production `rindled` WebSocket serves the **normalized** protocol. It does not serve flat results to `createRemoteStore`, even if you provide a lease resolver. The repository's private reference server includes a flat server for integration tests. See [custom remote backends](https://rindle.sh/docs/backends#remote-result-client) for the boundary and an example. ## Normalized sync: local reads without prediction `createNormalizedStore(schema, source)` combines the wasm engine with a `NormalizedSource`. The source supplies changes to the rows and columns needed by active server queries. The client shares those rows across subscriptions and computes local views from them. For WebSocket input, `createRemoteNormalizedSource` from `@rindle/remote` supplies that source. It understands normalized snapshots and changes. It can subscribe with an API-issued lease through `resolveSubscribe`. This composition does **not** include the full app connection lifecycle. Your integration owns lease requests, endpoint selection, affinity tickets, and rebuilding subscriptions after a reconnect. A returned `wsEndpoint` does not move this source's existing transport. `WsTransport` reconnects a socket, but this source does not automatically re-register its queries on that reconnect. Writes also need an explicit authority. `store.write` forwards raw mutations through the source and makes no local prediction. The standard app mutation API accepts named mutation envelopes, not these raw row operations. Use an application write endpoint, or implement a compatible `sendMutation` adapter for your own server. See [normalized composition](https://rindle.sh/docs/backends#normalized-client). ## Optimistic app client: the integrated connection `createRindleClient` initializes wasm, resolves named query leases through your API server, and opens the selected data-tier WebSocket. It manages mutation delivery, reconnects, subscription recovery, and supported endpoint changes. Call a named mutator to predict a write immediately. Your API server's HTTP adapter verifies the caller's credentials, and the API server runs the authoritative mutator. As confirmed changes arrive, the browser reapplies pending mutators to the confirmed rows. This is **rebase**. Rejected predictions are removed. The [client guide](https://rindle.sh/docs/client) explains construction, reads, writes, and cleanup. You can use this client without React. You can also use it for read-only screens and simply make no mutation calls. `createOptimisticStore` exposes the engine composition beneath this constructor. It accepts an `OptimisticSource` and a mutator registry. It does not initialize wasm or construct your app's HTTP and WebSocket connections for you. Use it when you are implementing a transport integration; the [backend guide](https://rindle.sh/docs/backends#custom-optimistic-composition) describes its contract. ## Understand local completeness A named query describes what the server must supply. A local query over a normalized or optimistic store reads only the rows currently held in the browser. It opens no subscription of its own. For example, a named query that retains 50 issues does not download the whole issue table. A local count over that table counts available rows. It cannot establish the server's total. Releasing server queries can also remove rows that no remaining subscription retains. Use a named query and its readiness signal when you need an authoritative result. Use a local query when the available rows are sufficient. See [preloads and query readiness](https://rindle.sh/docs/preloads). ## Add UI and storage features separately `@rindle/react` wraps a `Store`; it works with local and remote backends. TanStack Start adds routing and server integration. Neither choice determines whether your browser has optimistic writes. [Server rendering](https://rindle.sh/docs/ssr) uses a separate server store and a browser handoff. Do not construct the live browser client during a render on the server. None of these constructors automatically persists the synced database or a pending mutation queue across reloads. The optimistic app client can persist explicit local-only tables with `persistLocal`. That option covers UI data such as drafts, not a durable offline copy of synced data. See [local tables](https://rindle.sh/docs/local-only-tables) and [persisting local tables](https://rindle.sh/docs/persisting-local-tables). [SQL over HTTP](https://rindle.sh/docs/sql-client) is a separate server-code option. Its database token is trusted, so keep it behind an authorized application endpoint. SQL returns statement results; it does not create a live view or require an optimistic client. --- [View this page on Rindle](https://rindle.sh/docs/browser-clients) --- # Scaffold with create-rindle Generate and run a TanStack Start app with SQL migrations, a synced browser client, an API server, and SSR. Build an app with live messages, optimistic writes, and a shared database. `create-rindle` generates the browser client, API server, and local data tier in a [TanStack Start](https://tanstack.com/start) project. This guide starts the app on your computer. You will see a message sync between two browser windows, then change the generated project. Use this starter when you want a new synced TypeScript app with routing and server rendering included. To add sync to another framework, use the [manual quickstart](https://rindle.sh/docs/synced-app-quickstart). For local queries without a server, start with the [browser engine](https://rindle.sh/docs/wasm-client). For remote reads or normalized sync without prediction, compare the [browser clients](https://rindle.sh/docs/browser-clients). For an embedded Rust database, use [`rindle-replica`](https://rindle.sh/docs/replica-and-views). ## Before you start You need Node.js 22.18 or later and npm or pnpm. The starter uses TypeScript and React. It includes the local Rindle binaries and requires no cloud account. ## 1. Create the project With pnpm, run: ```bash pnpm create rindle my-app cd my-app pnpm dev ``` With npm, run: ```bash npm create rindle@latest my-app cd my-app npm run dev ``` The generator installs dependencies with your package manager. The development command starts the app at [localhost:3000](http://localhost:3000). ## 2. See live updates 1. Open [localhost:3000](http://localhost:3000) in two browser windows. 2. Create a room in the first window. 3. Open the room in both windows. 4. Post a message in the first window. The message appears immediately in the first window and syncs to the second. The room's message count updates with each message. To see a rejected write, create a room named `spam`. The room appears briefly, then disappears when the API server rejects it. A toast explains the rejection. The starter includes a development identity so you can write immediately. Before production, replace it with your own authentication provider. ## 3. Understand the development command The generated `dev` script runs one lifecycle command: ```bash rindle dev --migrate --gen shared/schema.gen.ts -- vite dev --port 3000 ``` `rindle dev` reads `rindle.ncl` and starts the local write master, follower, and fleet edge. The master accepts database writes. The follower serves live queries, and the edge routes requests to them. The command applies migrations and generates `shared/schema.gen.ts`. Then it starts TanStack Start with `RINDLE_URL` and `RINDLE_DATABASE_TOKEN`. The database token stays on the server. The browser calls the app's `/api/rindle/*` routes. An authorized subscription response, called a **lease**, gives it the endpoint for live updates. ## What it generates The starter generates the same three tiers as the manual quickstart: | Tier | In the template | What it does | | --- | --- | --- | | Browser | `src/rindle-client.ts`, `src/rindle-tanstack.ts`, `src/components/*.queries.ts`, `src/routes/*` | Displays live query results and predicts writes immediately | | API authority | `server/app-api.ts`, `server/rindle-http.ts`, `src/routes/api.rindle.*.tsx` | Checks access, resolves named queries, and runs authoritative mutators | | Data tier | `rindle.ncl`, `migrations/*.sql` | Stores data, applies migrations, and sends live query changes | The schema is SQL-first. Edit or add `migrations/*.sql`. The dev loop applies the migration to the write master and regenerates `shared/schema.gen.ts` from the follower's introspected schema. A file can be pure DDL (including reviewed destructive drops) or pure DML for seeds and bounded backfills. Keep the two kinds in separate ordered files. Keep relationships, normalization, and mutators in `shared/app-def.ts`. ## Why TanStack Start The starter uses TanStack Start because it gives the template one home for the browser, SSR, and server routes: - `src/routes/api.rindle.query.tsx`, `api.rindle.read.tsx`, and `api.rindle.mutate.tsx` are the browser-facing API routes. - [SSR reads](https://rindle.sh/docs/ssr) call the same app authority in-process, so first paint and client subscriptions use the same query registry. - `@rindle/tanstack` lets each file route declare its query once, then owns server preloading, client navigation readiness, cancellation, and the seed-to-live provider handoff. - TanStack Router's file routes keep app screens and Rindle query modules close without forcing the Rindle APIs themselves to depend on TanStack. This template chooses the optimistic client and a replicated data tier. Other Rindle integrations do not require that combination. TanStack Start is the template's application framework; the query engine and client APIs also work outside it. ## Where to look first | File | Why it matters | | --- | --- | | `migrations/0001_init.sql` | The source-of-truth SQL schema | | `shared/schema.gen.ts` | Generated `@rindle/client` schema; do not hand-edit | | `shared/app-def.ts` | Schema re-export, relationships, normalization, and predicted mutators | | `src/components/*.queries.ts` | Named root queries and fragments, co-located with UI | | `src/rindle-client.ts` | The one browser client setup call | | `src/rindle-tanstack.ts` | The shared route-loader/provider integration | | `src/ssr.ts` | Server-only first-paint preload helper | | `src/devtools.tsx` | Dev-only Rindle devtools panel mount | | `server/app-api.ts` | Authoritative query registry, SQL mutators, policy | | `rindle.ncl` | The topology `rindle dev` (local) and `rindle deploy` (cloud) both read — `followers = 1`, the colocated pair; loopback in dev | | `AGENTS.md` | App instructions for coding agents, including generated files, mutators, and named queries | ## Customize it Treat the generated app as a working baseline: - Change tables in `migrations/*.sql`, then let `pnpm dev` regenerate the typed schema. - Add relationships and mutators in `shared/app-def.ts`. - Add named root queries or fragments beside the components that read them. - Replace the demo auth seam in `shared/auth.ts` and the policy in `server/app-api.ts`. - If you point the app at Rindle Cloud or a self-hosted fleet, keep `RINDLE_DATABASE_TOKEN` server-only. The same `rindle.ncl` drives the cloud: 1. Run `rindle login`. 2. Run `pnpm rindle:deploy`. It provisions (or re-attaches) the **Sync** plan — a managed master + follower on Rindle's packed OVH fleet — and records the binding in `.rindle/cloud.json`. Commit that file. 3. Run `pnpm rindle:migrate:cloud` to push `migrations/*.sql` through the Cloud proxy. Local dev reads the same authored topology through `rindle dev`. See [deploy](https://rindle.sh/docs/deploy). For the underlying commands, see [`@rindle/cli`](https://rindle.sh/docs/rindle-cli). For the manual version of the same app shape, keep the [quickstart](https://rindle.sh/docs/synced-app-quickstart) open beside the generated project. ## Next steps - [Synced-app quickstart](https://rindle.sh/docs/synced-app-quickstart) - the same architecture, built by hand. - [The three-tier architecture](https://rindle.sh/docs/architecture) - why the browser, API authority, and daemon are separated. - [`@rindle/cli`](https://rindle.sh/docs/rindle-cli) - the `rindle` toolchain the template uses for the daemon, migrations, and schema generation. - [The browser client](https://rindle.sh/docs/client) - `createRindleClient`, optimistic mutators, and live reads. - [Server rendering](https://rindle.sh/docs/ssr) - the first-paint preload and live-store handoff. - [Devtools](https://rindle.sh/docs/devtools) - the dev-only mutation timeline, query inspector, and delta stream. --- [View this page on Rindle](https://rindle.sh/docs/create-rindle) --- # Synced-app quickstart Connect a SQL schema, named queries, shared mutators, browser client, and API server in an existing project. Build a small issue tracker with live queries and optimistic writes. An optimistic write updates the browser immediately, before the server accepts it. Open the app in two windows to see each accepted write sync between them. This guide assembles the [three tiers](https://rindle.sh/docs/architecture) in a Vite + React app: the browser, your API server, and the Rindle data tier. It introduces SQL migrations, a shared schema, named queries, and shared mutators in that order. A **mutator** is a function that describes a write. For a generated app with routing and server rendering, start with [`create-rindle`](https://rindle.sh/docs/create-rindle). Those integrations are optional. This guide uses a separate Node API server so you can see each tier. | File | Tier | What's in it | | --- | --- | --- | | `migrations/0001_init.sql` | schema (source of truth) | `CREATE TABLE` ×4 + indices | | `shared/schema.gen.ts` | **generated** | `rindle schema gen` output — don't hand-edit | | `shared/app-def.ts` | shared contract | relationships + the **isomorphic mutators** | | `src/IssueList.queries.ts` | shared contract | the **named query**, co-located with its component | | `src/rindle-client.ts` + UI | browser | `createRindleClient`, reads via `@rindle/react`, optimistic writes | | `server/api.ts` | API server | named queries → ASTs + the **same mutators** with server authority | ## 0 · Install You need **Node 22.18 or later** and pnpm. The API server runs TypeScript directly. Create the project, then install its dependencies: ```bash pnpm create vite my-app --template react-ts cd my-app pnpm install pnpm add @rindle/optimistic @rindle/client @rindle/wasm @rindle/react # browser pnpm add @rindle/api-server # API server pnpm add zod # mutator arg schemas pnpm add -D @rindle/cli concurrently # toolchain + process runner npx rindle init # writes rindle.ncl (the colocated pair, loopback) + migrations/ ``` Create the files in the following steps. Step 6 starts the database processes, applies migrations, generates the schema, and starts both app processes. Imports from `shared/schema.gen.ts` remain unresolved until that first run. ## 1 · The schema, in SQL SQL is the source of truth. Author the normalized schema as one migration. Every table needs a single `PRIMARY KEY`, and columns are `TEXT` / `INTEGER` / `REAL` / `BOOLEAN` / `JSON`. ```sql -- migrations/0001_init.sql CREATE TABLE IF NOT EXISTS user (id TEXT NOT NULL, name TEXT NOT NULL, PRIMARY KEY (id)); CREATE TABLE IF NOT EXISTS issue ( id TEXT NOT NULL, title TEXT NOT NULL, status TEXT NOT NULL, priority TEXT NOT NULL, ownerId TEXT NOT NULL, createdAt REAL NOT NULL, updatedAt REAL NOT NULL, PRIMARY KEY (id) ); CREATE INDEX IF NOT EXISTS issue_created ON issue (createdAt DESC, id); CREATE INDEX IF NOT EXISTS issue_owner ON issue (ownerId); CREATE TABLE IF NOT EXISTS tag ( id TEXT NOT NULL, issueId TEXT NOT NULL, name TEXT NOT NULL, PRIMARY KEY (id) ); CREATE INDEX IF NOT EXISTS tag_issue ON tag (issueId, name); CREATE TABLE IF NOT EXISTS comment ( id TEXT NOT NULL, issueId TEXT NOT NULL, authorId TEXT NOT NULL, body TEXT NOT NULL, createdAt REAL NOT NULL, PRIMARY KEY (id) ); CREATE INDEX IF NOT EXISTS comment_issue ON comment (issueId, createdAt); ``` `rindle schema gen` introspects the live daemon and emits `shared/schema.gen.ts` — one `const` per table plus the `createSchema` aggregate. Don't hand-edit it: ```ts // shared/schema.gen.ts — Generated by `rindle schema gen`. Do not edit by hand. import { createSchema, number, string, table } from "@rindle/client"; export const comment = table("comment") .columns({ id: string(), issueId: string(), authorId: string(), body: string(), createdAt: number() }) .primaryKey("id"); export const issue = table("issue") .columns({ id: string(), title: string(), status: string(), priority: string(), ownerId: string(), createdAt: number(), updatedAt: number(), }) .primaryKey("id"); export const tag = table("tag") .columns({ id: string(), issueId: string(), name: string() }) .primaryKey("id"); export const user = table("user") .columns({ id: string(), name: string() }) .primaryKey("id"); export const schema = createSchema({ tables: [comment, issue, tag, user] }); ``` Use Drizzle or any tool that emits `*.sql`, then `npx rindle migrate apply --dir ./drizzle`. See [schema & migrations](https://rindle.sh/docs/schema) for types, data migrations, and browser-only tables. ## 2 · The shared contract Both the browser and API server import the schema, relationships, query builder, and mutators. A **relationship** declares how columns join two tables. A shared mutator uses a JavaScript generator: each `yield` asks its caller to perform a database operation. The browser applies these operations locally. The API server applies the same operations in a SQL transaction. After server updates arrive, the browser reapplies pending mutators to the confirmed data. This process is called **rebase**. Generate IDs and timestamps before you call a mutator, then pass them as arguments. Each tier supplies the acting user through `ctx.user`. ```ts // shared/app-def.ts — imported by BOTH the browser and the API server import { defineMutators, defineRelationships, newQueryBuilder, rel } from "@rindle/client"; import type { MutationGen, MutatorCtx, Row } from "@rindle/client"; import type { ClientRegistry } from "@rindle/optimistic"; import { z } from "zod"; import { schema, comment, issue, tag, user } from "./schema.gen.ts"; export { schema, comment, issue, tag, user }; export const q = newQueryBuilder(schema); export type Issue = Row; export const rels = defineRelationships({ issueOwner: rel(issue, user, { ownerId: "id" }), issueComments: rel(issue, comment, { id: "issueId" }), issueTags: rel(issue, tag, { id: "issueId" }), }); export const createIssueArgs = z.object({ id: z.string(), title: z.string(), status: z.string(), priority: z.string(), createdAt: z.number(), }); export type CreateIssueArgs = z.infer; export const addCommentArgs = z.object({ id: z.string(), issueId: z.string(), body: z.string(), createdAt: z.number(), }); export type AddCommentArgs = z.infer; const { shared } = defineMutators(schema); export const mutators = { createIssue: shared(createIssueArgs, function* (tx, a: CreateIssueArgs, ctx: MutatorCtx): MutationGen { yield tx.insertIgnore("user", { id: ctx.user, name: ctx.user }); yield tx.insert("issue", { id: a.id, title: a.title, status: a.status, priority: a.priority, ownerId: ctx.user, createdAt: a.createdAt, updatedAt: a.createdAt, }); }), // writes the comment table → the issue's live commentCount ticks up on its own addComment: shared(addCommentArgs, function* (tx, a: AddCommentArgs, ctx: MutatorCtx): MutationGen { yield tx.insertIgnore("user", { id: ctx.user, name: ctx.user }); yield tx.insert("comment", { id: a.id, issueId: a.issueId, authorId: ctx.user, body: a.body, createdAt: a.createdAt }); yield tx.update("issue", { id: a.issueId, updatedAt: a.createdAt }); }), setStatus: shared( z.object({ id: z.string(), status: z.string(), updatedAt: z.number() }), function* (tx, a): MutationGen { yield tx.update("issue", { id: a.id, status: a.status, updatedAt: a.updatedAt }); }, ), // an isomorphic READ: the ownership guard runs identically on both tiers deleteIssue: shared(z.object({ id: z.string() }), function* (tx, a, ctx): MutationGen { const cur = (yield tx.row("issue", { id: a.id })) as Issue | undefined; if (!cur || cur.ownerId !== ctx.user) return; yield tx.delete("issue", { id: a.id }); }), } satisfies ClientRegistry; ``` The op vocabulary is `tx.insert` / `tx.update` (pk + changed columns) / `tx.upsert` / `tx.insertIgnore` / `tx.delete`, plus reads `yield tx.row(table, pk)` and `yield tx.query(builder)`. Both see this transaction's own earlier writes. Full detail: [isomorphic mutators](https://rindle.sh/docs/mutators). ## 3 · The named query To sync a query, give it a name with `defineQuery` and register it on the API server. The browser sends that name and its arguments. The server builds the approved query and arranges its subscription. The argument validator runs on both tiers. This query selects the newest issues and counts the comments for each issue. Rindle updates the result as issues and comments change: ```ts // src/IssueList.queries.ts — co-located with the component below import { defineQuery } from "@rindle/client"; import { q, rels } from "../shared/app-def.ts"; type IssuesPageArgs = { limit: number }; export const issuesPageQuery = defineQuery( "issuesPage", (raw): IssuesPageArgs => { const limit = (raw as IssuesPageArgs).limit; if (!Number.isInteger(limit) || limit < 1 || limit > 1000) throw new Error("bad limit"); return { limit }; }, ({ limit }: IssuesPageArgs) => q.issue.orderBy("createdAt", "desc").limit(limit).countAs("commentCount", rels.issueComments), ); ``` Join the owner row or tags onto each issue with `.sub(...)`, composed as [fragments](https://rindle.sh/docs/fragments). Subscribe to **windows** (order + `limit`), not whole tables. ## 4 · The browser client `createRindleClient` starts the browser engine and connects it to your API server. The API server authorizes subscriptions and accepts mutations. Rindle discovers the WebSocket endpoint for live updates automatically. This example uses a fixed development identity. Both browser windows act as `demo`: ```ts // src/rindle-client.ts import { createRindleClient } from "@rindle/optimistic"; import { mutators, schema } from "../shared/app-def.ts"; const currentUser = () => "demo"; export const app = await createRindleClient({ schema, mutators, user: () => currentUser(), // the acting principal a mutator sees as ctx.user api: { url: "", // same-origin: posts to /api/rindle/* (proxied in step 6) headers: () => ({ "x-user": currentUser() }), // a real app sends a session/JWT }, onRejected: (envelope, reason) => window.alert(`${envelope.name} rejected: ${reason}`), }); ``` Read live views with `useQuery`. Write through `app.mutate.(args)`, which drives the mutator against local tables **synchronously**, so the view updates before the call returns: ```tsx // src/main.tsx — replace the generated Vite entry point import { createRoot } from "react-dom/client"; import { Rindle, useQuery, useQueryStatus } from "@rindle/react"; import { issuesPageQuery } from "./IssueList.queries.ts"; import { app } from "./rindle-client.ts"; createRoot(document.getElementById("root")!).render( , ); function IssueList() { const query = issuesPageQuery({ limit: 50 }); const rows = useQuery(query); const status = useQueryStatus(query); function createIssue() { const title = window.prompt("Issue title"); if (!title?.trim()) return; app.mutate.createIssue({ id: crypto.randomUUID(), title: title.trim(), status: "todo", priority: "medium", createdAt: Date.now(), }); } return (

Issues

{status === "unknown" &&

Loading issues…

} {status === "complete" && rows.length === 0 &&

No issues yet.

}
    {rows.map((r) => (
  • {r.title} — {r.status} · {r.commentCount} comments{" "} {" "}
  • ))}
); } ``` The event handlers generate IDs and timestamps once per action. The shared mutators can reuse those arguments each time they run. The mutation's name and args (never its effects) go to the API server. Confirmed deltas stream back and the client **rebases**. A rejected write's optimistic rows vanish on their own. See [the browser client](https://rindle.sh/docs/client) for local reads, pending signals, and [folded writes](https://rindle.sh/docs/folded-mutations). ## 5 · The API server The API server decides which queries and writes a caller can use. It builds approved queries and runs the shared mutators against the database. The database token stays in this process. For this local demo, the server trusts the `x-user` header. This is a development identity, not authentication. Before deployment, replace it with a verified session and add the access rules your app needs. ```ts // server/api.ts import { createServer } from "node:http"; import { createRindleApiServer, registerQueries, RindleApiError, sharedApiMutators } from "@rindle/api-server"; import type { MutationContext } from "@rindle/api-server"; import type { MutatorCtx } from "@rindle/client"; import { issuesPageQuery } from "../src/IssueList.queries.ts"; import { mutators, schema } from "../shared/app-def.ts"; type User = string | undefined; const queries = registerQueries([issuesPageQuery]); const sharedCtx = (ctx: MutationContext): MutatorCtx => { if (!ctx.user) throw new Error("unauthenticated"); return { user: ctx.user }; }; const rindleUrl = process.env.RINDLE_URL; const databaseToken = process.env.RINDLE_DATABASE_TOKEN; if (!rindleUrl || !databaseToken) throw new Error("start this app with `rindle dev -- …`"); const api = createRindleApiServer({ rindle: { url: rindleUrl, token: databaseToken }, schema, queries, mutators: sharedApiMutators(mutators, sharedCtx), authorizeQuery: ({ user }) => typeof user === "string" && user.length > 0, authorizeMutation: ({ user }) => typeof user === "string" && user.length > 0, }); // You own the HTTP — @rindle/api-server is transport-agnostic. Mount the JSON handlers on api.routes. createServer((req, res) => { void (async () => { if (req.method !== "POST" || ![api.routes.query, api.routes.read, api.routes.mutate].includes(req.url ?? "")) { res.writeHead(404).end(); return; } const body = JSON.parse(await readBody(req)); const ctx = { user: req.headers["x-user"] as string | undefined, request: req }; // verify a JWT in prod const out = req.url === api.routes.query ? await api.handleQueryJson(body, ctx) : req.url === api.routes.read ? await api.handleReadJson(body, ctx) : await api.handleMutateJson(body, ctx); res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(out)); })().catch((error: unknown) => { const status = error instanceof RindleApiError ? error.status : error instanceof SyntaxError ? 400 : 500; console.error(error); res.writeHead(status, { "content-type": "application/json" }); res.end(JSON.stringify({ error: status === 500 ? "Internal server error" : String(error) })); }); }).listen(7700, "127.0.0.1"); function readBody(req: import("node:http").IncomingMessage): Promise { return new Promise((resolve, reject) => { let body = ""; req.on("data", (chunk) => (body += chunk)); req.on("end", () => resolve(body)); req.on("error", reject); }); } ``` If you need authority the client must **not** predict (a policy guard, relational SQL a keyed op can't express), override only that name next to the spread. See [the API server](https://rindle.sh/docs/api-server) for overrides, context-scoped queries, and the rejection shapes. ## 6 · Run it `rindle dev` owns the topology. It: 1. evaluates `rindle.ncl` 2. waits for the fleet 3. applies migrations 4. generates the schema 5. launches your app command with `RINDLE_URL` + `RINDLE_DATABASE_TOKEN` (An integrated framework uses only `-- vite dev`. This manual example runs two processes.) ```json // package.json { "scripts": { "dev": "rindle dev --migrate --gen shared/schema.gen.ts -- concurrently -k -n api,web \"node --watch server/api.ts\" \"vite\"" } } ``` ```ts // vite.config.ts — point /api at the API server import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; export default defineConfig({ plugins: [react()], server: { proxy: { "/api": "http://127.0.0.1:7700" } }, }); ``` ```bash pnpm dev ``` Open the URL printed by Vite in two browser windows. Click **Create issue** in one window. The issue appears immediately and then syncs to the other window. Click **Add comment** to increase its live comment count. Click **Mark done** to change its status. Both windows update without a refresh. ## Go to production Replace the demo identity with real authentication and review your query and mutation policies. Deploy the browser and API server on your chosen app host. On [Rindle Cloud](https://rindle.sh/docs/cloud-quickstart), configure the API server with `RINDLE_URL` and the server-only `RINDLE_DATABASE_TOKEN`. For a self-hosted data tier, see [deploying and scaling](https://rindle.sh/docs/deploy). The schema, queries, and shared mutators use the same APIs in both deployments. ## Next steps - [The three-tier architecture](https://rindle.sh/docs/architecture) — the topology, and the two round-trips drawn out. - Recipes: [folded mutations](https://rindle.sh/docs/folded-mutations) · [fragments](https://rindle.sh/docs/fragments) · [TanStack Start](https://rindle.sh/docs/tanstack) · [server rendering](https://rindle.sh/docs/ssr). - [Scaffold with create-rindle](https://rindle.sh/docs/create-rindle) — the same shape, generated. - [Troubleshooting](https://rindle.sh/docs/troubleshooting) — the rules that keep it correct, and how it breaks when one is. --- [View this page on Rindle](https://rindle.sh/docs/synced-app-quickstart) --- # Guides Build on your first example: query data, connect an application, and run it in production. Choose a task for your project. Each guide explains the relevant concepts and shows how to use them. For a first example, start with [Onboarding](https://rindle.sh/docs/overview) and [Getting started](https://rindle.sh/docs/getting-started). For an API, supported query shape, or configuration option, use [Reference](https://rindle.sh/docs/api). ## Understand the engine and sync - [How it works](https://rindle.sh/docs/how-it-works): follow a query from its definition to a maintained result. - [Architecture](https://rindle.sh/docs/architecture): understand the browser, API authority, and data tier in a synced app. - [Embedded replica runtime](https://rindle.sh/docs/replica-and-views): keep SQLite and live queries inside your Rust process. - [Browser client choices](https://rindle.sh/docs/browser-clients): choose local rows, remote results, normalized sync, or optimistic writes. ## Define data and live queries - [Schema and migrations](https://rindle.sh/docs/schema): define database tables and generate a typed schema. - [Queries and fragments](https://rindle.sh/docs/fragments): name shared queries and connect results to components. - [Live aggregates](https://rindle.sh/docs/live-aggregates): maintain counts, sums, and grouped results. - [Pagination](https://rindle.sh/docs/pagination): load more rows with stable ordering. - [Pinned queries](https://rindle.sh/docs/pinned-queries): keep server results available between requests. - [Refine schema types](https://rindle.sh/docs/refining-schema-types): give database values application-specific types. - [Rust delta consumer](https://rindle.sh/docs/example-rust): apply individual changes to your own result structure. ## Add writes and authorization - [Mutators](https://rindle.sh/docs/mutators): share write logic between the browser and server. - [Authorization](https://rindle.sh/docs/authorization): scope queries and writes to the authenticated user. - [Rejected writes](https://rindle.sh/docs/rejected-writes): show failures after the server rejects a prediction. - [Folded mutations](https://rindle.sh/docs/folded-mutations): combine frequent optimistic writes. - [Undo and redo](https://rindle.sh/docs/undo-redo): reverse application actions. - [Background writes](https://rindle.sh/docs/background-writes): change authoritative data from a server or job. ## Connect the user interface - [TanStack Start](https://rindle.sh/docs/tanstack): connect the router, providers, and live queries. - [Preloads](https://rindle.sh/docs/preloads) and [SSR](https://rindle.sh/docs/ssr): prepare data before navigation and render it on the server. - [Local-only tables](https://rindle.sh/docs/local-only-tables): keep drafts and selections on the device. - [Local persistence](https://rindle.sh/docs/persisting-local-tables): restore device data after a reload. - [Fine-grained reactivity](https://rindle.sh/docs/fine-grained-reactivity): subscribe to the values a component needs. - [Typeahead](https://rindle.sh/docs/typeahead): update query arguments as the user types. - [LLM streams](https://rindle.sh/docs/llm-streams) and [agents](https://rindle.sh/docs/agents): stream generated content into live views. ## Connect data sources and deploy - [PostgreSQL source](https://rindle.sh/docs/postgres-source): keep Postgres authoritative through the preview integration. - [Deployment](https://rindle.sh/docs/deploy): choose a topology and understand its recovery requirements. - [Cloud quickstart](https://rindle.sh/docs/cloud-quickstart): create a hosted deployment. - [Cloud connections](https://rindle.sh/docs/cloud-connect): connect applications and tools to a deployment. - [Cloud operations](https://rindle.sh/docs/cloud-scaling): inspect deployment state and switch managed plans. For ordinary SQL requests, start with the [SQL client reference](https://rindle.sh/docs/sql-client). SQL-only use does not require browser sync or optimistic mutators. ## Test and diagnose - [Testing](https://rindle.sh/docs/testing): exercise queries, mutations, and synchronization. - [Devtools](https://rindle.sh/docs/devtools): inspect subscriptions and optimistic state. - [Troubleshooting](https://rindle.sh/docs/troubleshooting): identify setup, query, and sync errors. The sidebar groups guides by topic. The optional integration filter narrows the list to Engine & SQL or Synced apps. --- [View this page on Rindle](https://rindle.sh/docs/guides) --- # The synced-app architecture Follow an authorized query and an optimistic write through the browser, application API, and Rindle data tier. This page describes the standard optimistic synced app built with `createRindleClient`, `createRindleApiServer`, and a Rindle data tier. The [scaffold](https://rindle.sh/docs/create-rindle) and [manual quickstart](https://rindle.sh/docs/synced-app-quickstart) use this composition. Rindle also works without this architecture. An [embedded Rust database](https://rindle.sh/docs/replica-and-views) needs no server. A [standalone browser store](https://rindle.sh/docs/wasm-client) needs no network. Other [browser clients](https://rindle.sh/docs/browser-clients) stream results or maintain local rows without optimistic prediction. React, TanStack Start, and SSR are optional. ## The three tiers | Tier | Owns | Does not own | | --- | --- | --- | | Browser | Local query results, pending predictions, connection lifecycle | Authoritative access decisions or database credentials | | Application API | Query definitions, argument validation, access policies, authoritative mutators | A persistent copy of the database | | Data tier | Durable rows, SQL transactions, maintained queries, subscription delivery | Your application's user authentication | Your HTTP handler authenticates the request and passes its verified user to the API handlers. `createRindleApiServer` runs the policies you configure. Importing this package does not add an authentication provider or discover row-level rules. The API server can run in your existing application server or a serverless function. The data tier remains available between requests. The browser receives rows through a separate WebSocket connection authorized by the API server. ## The shared app contract A typical project shares three kinds of definitions: - **Schema:** TypeScript table and column definitions generated from the database, plus application-owned relationships and type refinements. - **Named queries:** a stable name, an argument parser, and a query builder. The API server registers these definitions and can add user-specific restrictions. - **Shared mutators:** argument parsers and write logic that the browser can predict and the server can execute authoritatively. A shared mutator is optional when reads are all you need. An optimistic app uses mutators for writes to synced tables. It can also have server-only writers and [local-only tables](https://rindle.sh/docs/local-only-tables), which follow different write paths. The [manual quickstart](https://rindle.sh/docs/synced-app-quickstart) defines every file for this contract. [Schema](https://rindle.sh/docs/schema), [queries](https://rindle.sh/docs/fragments), and [mutators](https://rindle.sh/docs/mutators) explain how to extend it. ## Reads and writes ### Subscribing to a query 1. The browser materializes a named query with its arguments. 2. The client sends that name and those arguments to the application's query endpoint. 3. Your HTTP handler authenticates the caller. The API server validates the query arguments and applies the configured access policy. 4. The API server asks the data tier to maintain the approved query. 5. The data tier returns a **lease**: permission to subscribe to that query, with an expiry and a public WebSocket endpoint. 6. The browser presents the lease and receives an initial snapshot, then changes. The standard daemon stream carries **normalized rows**, identified by table and primary key. Overlapping subscriptions share local rows. The browser's WASM engine maintains the final query results over that local data. Data slices can arrive before commit; the client stages them until the stream's progress confirms them. A bare `store.query` on this client reads rows already present locally. It does not request more server data. A named query requests an authorized server subscription. Offline queries can only use rows the client still holds. ### Making a write 1. An event handler creates any IDs or timestamps and calls a named mutator. 2. The client runs the mutator against local rows and shows the predicted result. 3. The client sends a mutation envelope containing its identity, sequence number, mutator name, and arguments to the application API. 4. The server validates and authorizes the operation, then runs its authoritative implementation in a database transaction. 5. The data tier streams row changes and sends commit progress to the browser. 6. The client removes confirmed predictions and replays the remaining pending mutators. This replay is called **rebase**. Client and server results can differ: the server can see rows the browser lacks, or reject a write. Your UI handles loading, pending work, and [rejections](https://rindle.sh/docs/rejected-writes). Client-generated SQL, ASTs, and predicted row effects do not establish authority. The server resolves names and arguments through its own registry and policies. ## Where SQL fits Database-backed apps define tables through SQL migrations. The [SQL client](https://rindle.sh/docs/sql-client) also supports ordinary reads and transactions. A SQL `SELECT` returns a response; it does not create a live subscription. A job or existing service can write to the authoritative database through the supported [background write path](https://rindle.sh/docs/background-writes). The data tier captures those changes and updates affected subscriptions. Such writes do not predict anything in the browser. When the browser predicts a write, use the optimistic mutation protocol for its server execution. An unrelated SQL request does not acknowledge that client's pending mutation. ## The daemon's two planes A standard `rindled` exposes separate public and private interfaces: | Interface | Caller | Purpose | | --- | --- | --- | | Public WebSocket | Browser with an authorized lease | Initial normalized rows and live changes | | Private HTTP control | Application API with server credentials | Materialize, read, and manage queries | | SQL HTTP | Trusted server or script with a database token | SQL reads and writes allowed by the deployment | The browser calls your application's API routes. It does not receive the `RINDLE_DATABASE_TOKEN` or private daemon credentials. One standalone daemon can own the SQLite database, writer, and live queries. A replicated deployment separates the write master from read followers. The [deployment guide](https://rindle.sh/docs/deploy) explains those choices. They do not change the application's named-query and mutator contract. [PostgreSQL integration](https://rindle.sh/docs/postgres-source) uses another topology: Postgres remains authoritative, and a gateway sends its changes to Rindle followers. It is a preview with its own setup and recovery constraints. ## Where server rendering fits [SSR](https://rindle.sh/docs/ssr) reads a query once under the current request's authority. It puts that result in the initial page. This seed is a snapshot of query results, not a persisted browser database. The live client still establishes its subscriptions. The SSR guide defines the server read, shared query, browser boot function, and React boundary. The [TanStack adapter](https://rindle.sh/docs/tanstack) connects those pieces to route loading. Neither SSR nor TanStack is required for client-only rendering. ## The correctness contract The engine's contract is **view-after-write == fresh-query**. After it applies a change, its maintained view equals a fresh query over the same underlying data. This does not mean that every browser instantly sees the server's latest commit. Network delivery can lag, and an optimistic view can include unconfirmed writes. The sync protocol reconciles those states as authoritative updates arrive. ## Next steps - [Manual quickstart](https://rindle.sh/docs/synced-app-quickstart): build this composition with explicit files. - [Optimistic client](https://rindle.sh/docs/client): understand local reads, mutation progress, and cleanup. - [API server](https://rindle.sh/docs/api-server): configure query and mutation authority. - [Browser client choices](https://rindle.sh/docs/browser-clients): choose a different composition. The implementation lives in `packages/optimistic/src/client.ts`, `packages/api-server/src/index.ts`, and `rust/rindle-server/src/net/mod.rs`. One-shot reads and subscription leases use the API server's query authorization and resolution path. Mutations have their own authorization and transaction path. --- [View this page on Rindle](https://rindle.sh/docs/architecture) --- # Schema & migrations Define database tables with SQL, apply migrations, and generate TypeScript types for live queries. Rindle queries use **tables with typed columns**. For a Rindle database or synced app, SQL migrations define those tables. The CLI applies the migrations and generates a TypeScript schema for the [query builder](https://rindle.sh/docs/supported-queries-ts). The generated schema describes column types, primary keys, comparison rules, and JSON parsing. Regenerate it after SQL schema changes so the application matches the database. Keep relationships, mutators, and other handwritten definitions in separate files. Choose the schema workflow for your data source: | Data source | Schema workflow | | --- | --- | | Rindle database or synced app | SQL migrations and generated TypeScript, as described on this page | | Browser-only or in-memory engine | Handwritten [table definitions](https://rindle.sh/docs/wasm-client), with no SQL migration step | | Embedded Rust with SQLite | Your SQL schema and controlled writer. See [Embed SQLite and live queries](https://rindle.sh/docs/replica-and-views) | | Existing PostgreSQL database | PostgreSQL migrations and the [PostgreSQL source guide](https://rindle.sh/docs/postgres-source) | Local-only UI tables can extend a generated browser schema without a server migration. See [local-only tables](https://rindle.sh/docs/client#local-only-tables-drafts-selections-prefs). ## Migrations One toolchain does all of it: the **`rindle` CLI**, shipped beside the daemon. (Rust: installed with `rindled`. JS/TS: `npm i -D @rindle/cli`, then `npx rindle …`; see [`@rindle/cli`](https://rindle.sh/docs/rindle-cli) for the toolchain reference.) ```bash rindle init rindle dev --migrate --gen src/schema.gen.ts -- vite dev ``` `rindle init` writes the default loopback replicated topology and a `migrations/` folder. `rindle dev` renders that topology, supervises its processes, applies and watches migrations, regenerates the TypeScript schema, and runs your app with `RINDLE_URL` plus `RINDLE_DATABASE_TOKEN`. Set `profile = "standalone"` when one local `rindled` should own both reads and serialized writes; the migration and schema-generation loop is unchanged. The topology itself remains a small input record: ```text # rindle.ncl — the default replicated profile: a write-master + follower(s) { profile = "replicated", app = "my-app", followers = 1, # 1 = the colocated pair, both processes on one box } ``` Use `rindle up` only when you want the data tier without an app process. There's no table list anywhere — tables come from migrations. Standalone discovers them locally; in the replicated profile, followers discover them as the master's DDL replicates. Every non-empty migration file must be one of two pure kinds: - **DDL** — schema statements such as `CREATE`, `ALTER`, and `DROP`. - **DML** — data writes such as `INSERT`, `UPDATE`, and `DELETE`. DDL and DML cannot appear in the same file. If a backfill depends on an earlier schema change, keep their zero-padded filenames ordered. ### 1 · Author a schema migration ```bash rindle migrate create init # creates migrations/0001_init.sql ``` A schema migration is **ordinary SQL DDL** — one statement per `;`. Every table needs a declared **primary key**, which can span several columns. Key columns must not contain null. Declare a column's *kind* with its type name — including `BOOLEAN` and `JSON` (more below). Two habits pay off: use `IF NOT EXISTS` so a re-run is safe, and add an index for each direction your joins and windowed `orderBy`s traverse. Regenerate the TypeScript schema after a schema change so its column positions match the database. ```sql -- migrations/0001_init.sql CREATE TABLE IF NOT EXISTS issue ( id TEXT NOT NULL PRIMARY KEY, title TEXT NOT NULL, closed BOOLEAN NOT NULL DEFAULT 0, -- declared BOOLEAN → boolean() labels JSON NOT NULL DEFAULT '[]', -- declared JSON → json() priority INTEGER NOT NULL DEFAULT 0, createdAt REAL NOT NULL ); CREATE INDEX IF NOT EXISTS issue_created ON issue (createdAt DESC, id); -- the paginated window CREATE TABLE IF NOT EXISTS comment ( id TEXT NOT NULL PRIMARY KEY, issueId TEXT NOT NULL, body TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS comment_issue ON comment (issueId); -- the issue → comments join ``` ### 2 · Apply it ```bash rindle migrate apply # POSTs each *.sql to the daemon, in order, idempotently ``` The CLI classifies and checksums every file before sending the ordered batch. DDL still rejects `RENAME`, column type changes, and raw `blob`. Destructive statements print a loud notice first — see [evolving your schema](#evolving-your-schema). The write authority commits each schema migration in order. Standalone reshapes its own live-query state; the replicated master forwards the DDL so every follower reshapes. New tables are **auto-discovered** — you don't list them anywhere — and no manual restart is needed: ```text [migrate] applying 1 migration(s) from migrations/ → [applied] 0001_init schemaVersion=0001_init [migrate] done — 1 newly applied, 0 already present [migrate] schema committed on the write authority; followers, when present, apply DDL over replication — no manual restart needed. ``` `rindle migrate apply` is safe to re-run. The write authority binds each id to its kind and content checksum. An exact match reports `present`, while edited content or reusing a DDL id for DML fails instead of silently adopting it. `rindle migrate status` validates the local kinds and checksums against the applied journals. If an applied file was changed cosmetically and reverting it is no longer practical, the first line of the file can explicitly accept the checksum that actually ran: ```sql -- OVERRIDE_HASH: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef CREATE TABLE issue (...); ``` Copy the applied hash shown by `rindle migrate status`, then review the change before adding the directive. The directive is excluded from the SQL and the current checksum. The write authority accepts it only when it exactly names that migration id's stored hash. It never re-executes the edited SQL, changes the stored history, or permits a DDL/DML kind mismatch. A fresh database applies the current file normally, so use this escape hatch only when the old and current SQL are operationally equivalent. ### Data migrations Put seeds and bounded backfills in pure-DML files after any DDL they depend on: ```sql -- migrations/0002_add_color.sql (DDL) ALTER TABLE issue ADD COLUMN color TEXT; ``` ```sql -- migrations/0003_backfill_color.sql (DML) UPDATE issue SET color = 'red' WHERE priority >= 8; UPDATE issue SET color = 'blue' WHERE color IS NULL; ``` The write authority evaluates a data migration exactly once in one transaction. In the replicated profile it captures the concrete inserted, updated, deleted, cascaded, and conflict-resolved rows and ships those deltas to followers; followers never execute the migration SQL. This also makes values from `random()` or time functions converge exactly. An apply-once marker commits with the row changes, including when the DML affects zero rows. Data migrations accept statements classified as writes: `INSERT`, `UPDATE`, `DELETE`, and write CTEs. Reads, PRAGMAs, and explicit transaction control are rejected. A DML file does not change `schemaVersion` or reshape live-query state. One data migration can capture at most **8,191 user-row changes** (including cascades) and **64 MiB** of encoded row data. The marker consumes the final change in HCTree's 8,192-change transaction budget. If a file crosses either limit, the write authority rolls it back. Split a large backfill into explicitly key-ranged, separately numbered files. Mixed DDL+DML table rebuilds are not atomic in v1. Express an additive change and its backfill as two ordered files. The public `SqlClient.migrate()` surface remains DDL-only. Deploy data migrations with `rindle migrate apply`, locally or with `--cloud`. ### 3 · Generate the typed schema ```bash rindle schema gen --out src/schema.gen.ts ``` This reads the daemon's introspected schema (`GET /schema`) and emits the `@rindle/client` definition — one `const` per table, sorted by name, plus the `createSchema` aggregate: ```ts // Generated by `rindle schema gen` from the daemon's introspected schema (GET /schema). // Do not edit by hand — re-run the generator after each migration. import { boolean, createSchema, json, number, string, table } from "@rindle/client"; export const comment = table("comment") .columns({ id: string(), issueId: string(), body: string(), }) .primaryKey("id"); export const issue = table("issue") .columns({ id: string(), title: string(), closed: boolean(), // ← from the declared BOOLEAN labels: json(), // ← from the declared JSON priority: number(), createdAt: number(), }) .primaryKey("id"); export const schema = createSchema({ tables: [comment, issue] }); ``` That's the whole loop: **edit SQL → `migrate apply` → `schema gen`.** Re-run the last two after every schema change. ### Adding local-only client tables to a generated schema Do **not** hand-edit the generated file for browser-only tables such as drafts, selections, or view preferences. Define those tables in a separate module and extend the generated schema: ```ts // src/schema.local.ts import { extendSchema, string, table } from "@rindle/client"; import { schema as generatedSchema } from "./schema.gen.ts"; export const selection = table("selection", { local: true }) .columns({ id: string(), issueId: string() }) .primaryKey("id"); export const clientSchema = extendSchema(generatedSchema, { tables: [selection] }); ``` Use `clientSchema` in the browser. Keep using the generated `schema` for your API server and any daemon-facing named-query registry. `extendSchema` accepts only `{ local: true }` tables, which keeps real synced tables SQL-first and generated from daemon introspection. ## Column types: arbitrary type names SQLite's declared type names carry more information than its storage classes. Rindle uses those names to choose column kinds and generate TypeScript definitions. Its database admission and write paths enforce additional supported-value rules. | SQL declaration | Generated type | Behavior | | --- | --- | --- | | `TEXT`, `VARCHAR(n)`, `CHAR`, `CLOB` | `string()` | Text | | `INTEGER`, `REAL`, `NUMERIC`, `DECIMAL` | `number()` | JavaScript numbers; integer values must be exactly representable | | Exactly `BIGINT` or `INT8` | `int64()` | Exact signed 64-bit integers, exposed as `bigint` | | `BOOLEAN` or `BOOL` | `boolean()` | Boolean values | | `JSON` or `JSONB` | `json()` | JSON stored as text and parsed by the client | | `BLOB` | Unsupported for captured tables | Store an encoded text representation if appropriate | A bare `INTEGER` intended as a boolean still generates `number()`. Declare it `BOOLEAN` to express that intent. A non-exact spelling such as `UNSIGNED BIGINT` does not opt into the `BIGINT`/`INT8` behavior. Exact int64 values can round-trip through SQL and replication. Maintained queries currently reject an int64 column in their required data, including a primary key. Selecting only other columns can work when the query does not need the int64 column for identity, filtering, ordering, or another operation. The generator cannot infer a JSON interface or a string literal union. Use [refineTable and refineSchema](https://rindle.sh/docs/refining-schema-types) in a handwritten module. Do not add casts to the generated file. Refinements change types, not stored-value validation. ## What the generated schema is for The schema supplies both TypeScript types and runtime metadata: - **Typed queries and rows.** `schema` types `store.query.` and the rows you read back, so `where`/`orderBy`/`select` are checked against real columns and a result is `{ id: string; closed: boolean; labels: string[] }`, not `any`. - **The comparator.** Each column's kind drives ordering (strings bytewise, numbers by total order, booleans as 0/1) so a client sorts a view exactly as the engine does. - **`json` parsing.** `json` columns arrive as text on the wire and are parsed to objects once, on read. What it is **not**: it carries **no relationships**. Query correlations (`issue.id → comment.issueId`) live in your [named queries and fragments](https://rindle.sh/docs/fragments), not in the schema. That is why plain SQL introspection (columns + PK) is enough to generate it. The normalized client also checks advertised table names, column compatibility, and primary keys. It accepts supported projections and additive expansions. These checks do not regenerate your application code or validate every type refinement. Regenerate and ship the schema with application changes. Import the schema wherever you build queries and configure a backend. The [manual quickstart](https://rindle.sh/docs/synced-app-quickstart) defines these imports for the browser and API server, while local table extensions belong only in the browser schema. ## Evolving your schema Migrations cover both directions of schema change: - **Additive** — `CREATE TABLE`, `ADD COLUMN`, `CREATE INDEX`. - **Destructive** — `DROP TABLE`, `ALTER TABLE … DROP COLUMN`, `DROP INDEX`. A drop deletes the schema (and its data) on the write authority and **every follower**, when present. `rindle migrate apply` prints a `[destructive]` notice per statement before sending anything. There is no flag to set — the reviewed migration file is the consent. A replicated backup or an operator-created standalone snapshot is the undo. Still rejected: `RENAME` (expand instead: add the new column/table, move writes in your app, then drop the old one), column type changes, and raw `blob` columns. Each applied **DDL** file advances the write authority's `schemaVersion`, which namespaces live-query results. An old-schema client can't attach to a new-shape view, so after the daemon reshapes it re-leases against the new version. A DML file advances the ordered write cursor but leaves `schemaVersion` unchanged. After any schema change, **re-run `rindle schema gen`** and ship the regenerated schema with your client. Migrations are the one way to shape the schema: `rindle migrate apply` sends your DDL to the write authority, which replicates it to every follower when present. There's no inline table list to maintain. ### Dropping safely: contract like you expand Order a removal the same way you order an addition, just reversed: 1. **Ship the app without the doomed table/column first** — remove it from named queries, fragments, mutators, and room declarations. A query that still names it after the drop fails cleanly (that one query errors, and nothing else is affected) — visible, not corrupt — but there's no reason to ship that. 2. **Apply the drop migration.** The daemon reshapes and clients re-lease + re-hydrate automatically. 3. **Regenerate** (`rindle schema gen`) so the typed schema no longer mentions it. Two SQLite rules worth knowing: you can't drop a **primary-key** column, and you can't drop an **indexed** column directly. Drop the index first, in the same migration: ```sql -- migrations/0007_remove_priority.sql DROP INDEX IF EXISTS issue_priority; ALTER TABLE issue DROP COLUMN priority; ``` If you declared **foreign keys**, drop in dependency order. The write authority enforces `foreign_keys = ON`, and dropping a table that other tables still reference is refused — whether or not either table holds rows. The error names the cause and the fix. Drop the referencing tables first — the order composes in one migration: ```sql -- migrations/0008_remove_comments.sql DROP TABLE comment; -- references issue(id) DROP TABLE issue; ``` Dropping a referenced table while **keeping** a table that points at it is not supported. SQLite cannot drop a foreign-key constraint in place, and the dangling reference breaks the surviving table's writes. This holds even if you recreate the referenced table under the same name with a different key. A `DROP TABLE parent; CREATE TABLE parent (…)` that no longer carries the referenced column is refused too (a same-shape rebuild is fine). To keep that data, expand-contract it: create a replacement table without the foreign key in a DDL migration. Move the rows with a bounded DML migration and move application writes. Then drop both old tables. A replicator host that configures startup table definitions (`TableSpec`) has an additional constraint: those declared tables are **pinned**. The declaration re-creates them at every boot, so a migration that drops one is refused. Remove the table from the declaration (redeploy), then apply the drop. Apps built on the migration-first flow above declare nothing and never see this. A direct embedded `Db` has a different migration lifecycle. Its `exec_ddl` rejects schema changes to registered tables. Close and reopen the runtime, apply DDL before table registration, then recreate its queries. The [embedded guide](https://rindle.sh/docs/replica-and-views#registering-tables) describes this contract; it does not use the daemon's automatic reshape path. ## Handwritten schemas for in-memory stores A [standalone WASM store](https://rindle.sh/docs/wasm-client) has no SQLite database to introspect. Define its tables with `table(...).columns(...)` and push rows through `store.write`. Handwritten schemas also describe local-only tables and application-owned source integrations. For a standard daemon-backed app, generate the synced table definitions from SQL so their column order, keys, and kinds match the database. ## Next steps - [Run the daemon](https://rindle.sh/docs/daemon) — standalone owns reads and writes; a replicated follower serves `/schema` while its master serves `/migrate`. - [The browser client](https://rindle.sh/docs/client) — imports the generated `schema` to run live, optimistic queries. - [Supported query shapes](https://rindle.sh/docs/supported-queries-ts) — what the typed builder can lower. - [Reactive queries in the browser](https://rindle.sh/docs/wasm-client) — the standalone engine, where you author the schema by hand. --- [View this page on Rindle](https://rindle.sh/docs/schema) --- # Compose the UI with fragments Define component data requirements, combine them into named queries, and subscribe through useRoot and useFragment. A **fragment** describes the columns and relationships that a component reads. A query combines fragments with `.include()` and `.sub()`. React components receive references to rows, then read their fragments with `useFragment`. In a synced app, the root query requests the data for the component tree. Each fragment reader opens a separate local read and retains that same root query. It does not send a separate query for each row. ## Run a small example This independent example uses an in-memory browser store with two tables. It needs no API server or SQL migration. It does not use the schema from the [synced-app quickstart](https://rindle.sh/docs/synced-app-quickstart). Create a Vite React project: ```sh pnpm create vite fragment-demo --template react-ts cd fragment-demo pnpm install pnpm add @rindle/client @rindle/wasm @rindle/react ``` Define the tables, fragments, and named query: ```ts // shared/fragments.ts import { createSchema, defineFragment, defineQuery, newQueryBuilder, string, table, } from "@rindle/client"; import type { FragmentRef } from "@rindle/client"; export const issue = table("issue") .columns({ id: string(), title: string() }) .primaryKey("id"); export const comment = table("comment") .columns({ id: string(), issueId: string(), body: string() }) .primaryKey("id"); export const schema = createSchema({ tables: [issue, comment] }); export const q = newQueryBuilder(schema); export const CommentFragment = defineFragment(comment, (c) => c.select("id", "body"), ); export const IssueCardFragment = defineFragment(issue, (i) => i.select("id", "title").sub( "comments", comment, { parent: ["id"], child: ["issueId"] }, CommentFragment, (comments) => comments.orderBy("id", "asc"), ), ); export type CommentRef = FragmentRef; export type IssueCardRef = FragmentRef; export const issueCardsQuery = defineQuery("issueCards", () => q.issue.orderBy("id", "asc").limit(20).include(IssueCardFragment), ); ``` The correlation joins `issue.id` to `comment.issueId`. The final `.sub()` callback orders the comments within each issue. `issueCardsQuery` includes both fragments in one query definition. Defining it performs no I/O. Create the browser store and its initial rows: ```ts // src/local-store.ts import { createWasmStore, initWasm } from "@rindle/wasm"; import wasmUrl from "@rindle/wasm/pkg/rindle_bg.wasm?url"; import { schema } from "../shared/fragments.ts"; await initWasm(wasmUrl); export const store = await createWasmStore(schema); await store.write((tx) => { tx.add("issue", { id: "i1", title: "Ship the example" }); tx.add("comment", { id: "c1", issueId: "i1", body: "Add a screenshot." }); tx.add("comment", { id: "c2", issueId: "i1", body: "Review the instructions." }); }); ``` The `?url` import lets Vite serve the WASM asset. The store holds these rows in memory. Reloading the page creates the example again. ## Read fragments in React `useRoot(query, fragment)` returns row references and query status. `useFragment(fragment, ref)` reads one reference from the local store. Nested fragment relationships also return references. ```tsx // src/IssueCards.tsx import { fragmentKey, useFragment, useRoot } from "@rindle/react"; import { CommentFragment, IssueCardFragment, issueCardsQuery, } from "../shared/fragments.ts"; import type { CommentRef, IssueCardRef } from "../shared/fragments.ts"; export function CommentView({ comment }: { comment: CommentRef }) { const data = useFragment(CommentFragment, comment); if (data === null) return null; return
  • {data.body}
  • ; } function IssueCard({ issue }: { issue: IssueCardRef }) { const data = useFragment(IssueCardFragment, issue); if (data === null) return null; return (

    {data.title}

      {data.comments.map((comment) => ( ))}
    ); } export function IssueCards() { const [issues, { status }] = useRoot(issueCardsQuery, IssueCardFragment); if (issues.length === 0) { return

    {status === "complete" ? "No issues." : "Loading issues…"}

    ; } return issues.map((issue) => ( )); } ``` References are opaque tokens. They are not projected rows or database IDs. Use `fragmentKey(ref)` for a React key. Do not construct a reference by hand. A fragment read can return `null` if its row is absent or deleted. Replace the Vite entry point: ```tsx // src/main.tsx import { createRoot } from "react-dom/client"; import { Rindle } from "@rindle/react"; import { IssueCards } from "./IssueCards.tsx"; import { store } from "./local-store.ts"; const container = document.getElementById("root"); if (!container) throw new Error("Missing #root element"); createRoot(container).render( , ); ``` Run `pnpm dev`. The page shows one issue and two comments. ## Use the same pattern in a synced app For a synced app, use its generated table definitions and its client store. Register `issueCardsQuery` in the API server's named-query registry. The [synced-app quickstart](https://rindle.sh/docs/synced-app-quickstart) defines that server and registration step. Your SQL tables must contain the columns used by the fragments. The named query provides **coverage**: the rows and fields the server keeps available locally. The root and its fragment readers share this coverage subscription. Each reader still has its own local materialized view. Local rows can render before the server confirms coverage. `status === "unknown"` means coverage is not yet confirmed. It does not mean that every visible row is absent or invalid. Use `status === "complete"` before presenting an empty result as authoritative. Fragments organize reads. Authorization still belongs in the [API server](https://rindle.sh/docs/authorization). ## Choose the result shape | API | React result | | --- | --- | | `useQuery(query)` | Projected rows, including nested row data | | `useRoot(query)` | Root row data, with nested fragment relationships represented as references | | `useRoot(query, fragment)` | References for the root fragment | | `useFragment(fragment, ref)` | One fragment's data, or `null` | An inline `.sub()` builder returns nested data rather than fragment references. Use a named fragment in `.sub()` when child components need separate local reads. Use `.one()` on the root query for a single result instead of an array. Continue with [fine-grained reactivity](https://rindle.sh/docs/fine-grained-reactivity) to see which edits update each component. For route loading and server rendering, see [preloads](https://rindle.sh/docs/preloads) and [SSR](https://rindle.sh/docs/ssr). --- [View this page on Rindle](https://rindle.sh/docs/fragments) --- # Live counts & aggregates Maintain counts and grouped aggregates as rows change, including counts attached to related parent rows. Rindle maintains aggregates as source rows change. Use a relationship count to attach a number to each parent, or a root count to return aggregate rows. The public aggregate surface differs by language: | Result | TypeScript builder | Rust builder | | --- | --- | --- | | Count matching rows | `count()` | `count()` | | Grouped count | `groupBy(...).count()` | `group_by(...).count()` | | Count related children | `countAs(...)` | `count_as(...)` | | Sum or average | Not currently exposed | `sum`, `avg`, `sum_as`, `avg_as` | | Minimum or maximum | Not currently exposed | Not currently exposed | This guide starts with a complete TypeScript example. The [Rust query reference](https://rindle.sh/docs/supported-queries#aggregates) covers its additional aggregate methods. ## Run a local count Use the [browser store setup](https://rindle.sh/docs/wasm-client#install), then put this code in your entry module. It defines both tables and observes the counts after writes: ```ts import { boolean, createSchema, createWasmStore, gt, number, string, table, } from "@rindle/wasm"; const issue = table("issue").columns({ id: number(), title: string(), open: boolean(), status: string(), }).primaryKey("id"); const comment = table("comment").columns({ id: number(), issueId: number(), body: string(), }).primaryKey("id"); const schema = createSchema({ tables: [issue, comment] }); const store = await createWasmStore(schema); const perIssue = store.query.issue .countAs("commentCount", comment, { parent: ["id"], child: ["issueId"] }) .orderBy("id", "asc") .materialize(); const openCount = store.query.issue.where.open(true).count().materialize(); const byStatus = store.query.issue .groupBy("status") .count() .having((h) => h.count(gt(0))) .materialize(); const unsubscribe = perIssue.subscribe((rows) => console.log("issues", rows)); await store.write((tx) => { tx.add("issue", { id: 1, title: "First", open: true, status: "todo" }); tx.add("issue", { id: 2, title: "Second", open: false, status: "done" }); tx.add("comment", { id: 10, issueId: 1, body: "Ready" }); tx.add("comment", { id: 11, issueId: 1, body: "Reviewed" }); }); console.log(openCount.data); // [{ count: 1 }] console.log(byStatus.data); // one count row for "done" and one for "todo" console.assert(perIssue.data[0]?.commentCount === 2); console.assert(perIssue.data[1]?.commentCount === 0); await store.write((tx) => tx.remove("comment", { id: 11, issueId: 1, body: "Reviewed", })); console.assert(perIssue.data[0]?.commentCount === 1); unsubscribe(); perIssue.destroy(); openCount.destroy(); byStatus.destroy(); ``` `countAs` keeps each issue row and adds a scalar `commentCount`. It produces `0` for an issue without comments. `count()` returns one `{ count }` row, including `{ count: 0 }` when no rows match. A grouped count produces one row per existing group; a group disappears when its last row leaves. `where` filters input rows before aggregation. `having` filters the aggregate output. The result still updates from source changes, but hydration must first read and aggregate the matching input. One change can affect several parents. ## Reuse a relationship A [named relationship](https://rindle.sh/docs/fragments) avoids repeating its key mapping: ```ts import { defineRelationships, rel } from "@rindle/client"; const relationships = defineRelationships({ issueComments: rel(issue, comment, { id: "issueId" }), }); const query = store.query.issue.countAs("commentCount", relationships.issueComments); ``` This fragment reuses the tables and store above. It builds a query without materializing another view. `countAs` also accepts a child builder for a filtered count, such as counting only comments that match a predicate. ## Filter parents by child count The parent-query `having(alias, operator, value)` overload refers to an existing `countAs` alias: ```ts const activeIssues = store.query.issue .countAs("commentCount", comment, { parent: ["id"], child: ["issueId"] }) .having("commentCount", ">", 10); ``` This returns issues with more than ten comments. It keeps the full count on each surviving issue. It differs from `.having((h) => ...)`, which filters root aggregate rows. The parent-count filter currently accepts one numeric comparison that is false at zero. `> 0`, `>= 2`, `= 2`, and `!= 0` work. Predicates such as `= 0`, `>= 0`, and `!= 1` are rejected because a childless parent has no aggregate group to test. ## Use counts in a synced app Put the aggregate in a [named query](https://rindle.sh/docs/client) when the server must supply its data. Keep that query's subscription active while the UI needs updates. A bare local query counts the rows available in the local store; it does not request all matching server rows. The sync path can supply precomputed aggregate rows. A server count can therefore cover more children than the browser materializes. Do not infer that every counted child is available for a local detail query. Request that detail through its own authorized named query. ## Current limits A root aggregate cannot also select base columns, attach relationships, sort or limit groups, use paging, or apply `one()`. Root aggregates also reject correlated subqueries in `where` or `having`. Group columns and the synthetic aggregate column form the output. The TypeScript builder currently exposes counts only. Rust's sums and averages ignore null inputs and return `NULL` for empty or all-null input. These are public Rust methods, not TypeScript methods with different spelling. See [TypeScript query shapes](https://rindle.sh/docs/supported-queries-ts#aggregates) and [Rust query shapes](https://rindle.sh/docs/supported-queries#aggregates) for supported combinations. --- [View this page on Rindle](https://rindle.sh/docs/live-aggregates) --- # Pagination & infinite scroll Choose a growing live window or keyset pages, and define stable ordering and cursors. A paginated query maintains a bounded result while its source rows change. Choose between one growing window and several fixed-size pages: | Pattern | Use it for | Tradeoff | | --- | --- | --- | | Grow `limit` | A contiguous live list | The view grows with the visible list | | Fixed cursor per page | Independently retained pages | Live edits can create gaps or duplicates between pages | These recipes extend the [synced-app quickstart](https://rindle.sh/docs/synced-app-quickstart). They import its `q` builder from `shared/app-def.ts` and use its `issue` table. The components must render under its existing `Rindle` provider. The query definitions also work with a local store that has the same schema. ## Define a complete order Both recipes order issues by `updatedAt`, then by `id`. The unique ID resolves ties between equal timestamps. A cursor must carry both values in that same order. Add these named queries: ```ts // shared/pagination.ts import { defineQuery } from "@rindle/client"; import { z } from "zod"; import { q } from "./app-def.ts"; export const PAGE_SIZE = 50; export const MAX_VISIBLE = 500; const pageArgs = z.object({ limit: z.number().int().min(1).max(MAX_VISIBLE), }); export const paginatedIssuesQuery = defineQuery( "paginatedIssues", (raw) => pageArgs.parse(raw), ({ limit }) => q.issue .select("id", "title", "status", "updatedAt") .orderBy("updatedAt", "desc") .orderBy("id", "desc") .limit(limit), ); const cursorSchema = z.object({ updatedAt: z.number(), id: z.string() }); const afterArgs = z.object({ cursor: cursorSchema.nullable() }); export type IssueCursor = z.infer; export const issuesAfterQuery = defineQuery( "issuesAfter", (raw) => afterArgs.parse(raw), ({ cursor }) => { const query = q.issue .select("id", "title", "status", "updatedAt") .orderBy("updatedAt", "desc") .orderBy("id", "desc") .limit(PAGE_SIZE); return cursor ? query.start(cursor, { exclusive: true }) : query; }, ); ``` For the synced example, import both query definitions in `server/api.ts`. Add them to its existing `registerQueries([...])` array, alongside `issuesPageQuery`. The same server authorization rules apply to each page. ## Grow one live window Increasing the limit creates a new query with the same ordering. The local store can reuse retained rows while the server supplies the larger result. ```tsx // src/PaginatedIssues.tsx import { useState } from "react"; import { useQuery, useQueryStatus } from "@rindle/react"; import { MAX_VISIBLE, PAGE_SIZE, paginatedIssuesQuery } from "../shared/pagination.ts"; export function PaginatedIssues() { const [limit, setLimit] = useState(PAGE_SIZE); const query = paginatedIssuesQuery({ limit }); const rows = useQuery(query); const status = useQueryStatus(query); const complete = status === "complete"; return (
      {rows.map((row) =>
    • {row.title}
    • )}
    {!complete &&

    Loading the current window…

    } {complete && rows.length === 0 &&

    No issues.

    } {complete && rows.length === limit && limit < MAX_VISIBLE && ( )}
    ); } ``` Render `` in the quickstart's app. The example waits for complete coverage before deciding whether another page is available. A full window can still be the final window. Increasing its limit discovers that boundary. The result contains at most `limit` rows, ordered over the data available to the view. In a synced client, visible rows can include local predictions before server coverage completes. After coverage completes, the server has supplied the requested result. As rows arrive or change, this single window remains contiguous for its query. Its storage and maintenance cost grows with its limit. The example caps that growth at 500 rows. The React provider retains released queries for two seconds by default. This retention helps the replacement query reuse local rows. It does not guarantee that every navigation or network response finishes in that interval. See [client query retention](https://rindle.sh/docs/client). ## Retain fixed-size pages A keyset cursor is the last row's sort values. `.start(cursor, { exclusive: true })` selects rows after those values. It follows the query's descending order in this example. ```tsx // src/IssueFeed.tsx import { useState } from "react"; import { useQuery, useQueryStatus } from "@rindle/react"; import { issuesAfterQuery, PAGE_SIZE } from "../shared/pagination.ts"; import type { IssueCursor } from "../shared/pagination.ts"; export function IssueFeed() { const [cursors, setCursors] = useState>([null]); return (
    {cursors.map((cursor, index) => ( setCursors((current) => current.length === index + 1 ? [...current, next] : current, )} /> ))}
    ); } function IssuePage({ cursor, isLast, onNext }: { cursor: IssueCursor | null; isLast: boolean; onNext: (cursor: IssueCursor) => void; }) { const query = issuesAfterQuery({ cursor }); const rows = useQuery(query); const status = useQueryStatus(query); const last = rows.at(-1); const complete = status === "complete"; return (
      {rows.map((row) =>
    • {row.title}
    • )}
    {!complete &&

    Loading this page…

    } {isLast && complete && rows.length === PAGE_SIZE && last && ( )} {isLast && complete && rows.length < PAGE_SIZE &&

    End of the current result.

    }
    ); } ``` Render `` as an alternative to ``. Each mounted page retains its own query. The final page can append its successor only once. The reset button removes later pages and releases their readers. Fixed cursors do not form a transaction snapshot across pages. An insertion in page one can displace its final row before page two's fixed cursor. That row then appears in neither page. Changes to sort values can also create overlapping results between pages. Use one growing window when the complete visible list must remain contiguous. Each page has a fixed maximum size, but all mounted pages still consume resources. For long feeds, define an application limit or unmount pages that are no longer needed. Unmounted pages require retention management or another load when the user returns. ## Index the server's page reads For SQLite storage, a matching index can reduce work for ordered page reads. Add it in a new SQL migration: ```sql CREATE INDEX IF NOT EXISTS issue_updated ON issue (updatedAt DESC, id DESC); ``` Use the [CLI query analysis tools](https://rindle.sh/docs/rindle-cli) to inspect your actual query plan. An index's benefit depends on the filters, ordering, and data distribution. See [query shapes](https://rindle.sh/docs/supported-queries-ts) for `limit` and `start`, [preloads](https://rindle.sh/docs/preloads) for route preparation, and [live aggregates](https://rindle.sh/docs/live-aggregates) for a separate live total. --- [View this page on Rindle](https://rindle.sh/docs/pagination) --- # Pinned queries Keep a server query result current between requests, even with no subscribers, and read it without a live client. A pinned query is a live query whose result stays materialized with no subscribers. Use one for a frequently read public page, leaderboard, or service response. The engine updates the result as it applies source changes. The consumer makes a one-shot request for rows. It does not need WebAssembly, browser sync, optimistic writes, or a UI framework. ## Before you start You need a Rindle deployment with live-query support and an [API server connection](https://rindle.sh/docs/api-server#the-server). Define the query in the server query registry. Use the [TypeScript query guide](https://rindle.sh/docs/supported-queries-ts) for supported shapes. This example assumes the SQL-generated `issue` table from the [manual synced-app setup](https://rindle.sh/docs/synced-app-quickstart), with `id`, `title`, and `createdAt` columns. It defines one public, fixed-size query and reads its rows from server code: ```ts // server/pinned-issues.ts import { defineQuery, newQueryBuilder } from "@rindle/client"; import { createRindleApiServer, registerQueries } from "@rindle/api-server"; import { schema } from "../shared/schema.gen.ts"; const q = newQueryBuilder(schema); const publicIssues = defineQuery("publicIssues", () => q.issue.select("id", "title").orderBy("createdAt", "desc").limit(50), ); const api = createRindleApiServer({ rindle: {}, // Reads RINDLE_URL and RINDLE_DATABASE_TOKEN on the server. queries: registerQueries([publicIssues]), pinnedQueries: [{ name: "publicIssues" }], }); try { await api.assertPins(); const result = await api.readQuery({ user: undefined, name: "publicIssues", args: null, }); console.log(result.rows); } finally { api.close(); } ``` The sample query is intentionally public. It has no per-user filter. For a private query, supply your authenticated request context and authorize it as shown in the [API server guide](https://rindle.sh/docs/api-server). `assertPins()` creates the configured materializations. Repeating the call reuses an existing result for the same canonical query. In a long-running service, create the API instance at startup, assert its pins, and close it at shutdown. Closing the API client's transport does not unpin the daemon's result. ## Read the result Your request handler can call `api.readQuery` or expose `api.handleReadJson`. Both resolve the named query under the request context and apply the configured query authorization. See the [HTTP handler example](https://rindle.sh/docs/api-server#bring-your-own-http) for request wiring. A one-shot response contains `{ rows, cvMin, queryKey }`. It returns the current assembled rows without opening a subscription. The [SSR integration](https://rindle.sh/docs/ssr) uses the same read operation to seed a page before browser hydration. Reading the matching pinned query reuses its maintained result. An unpinned one-shot query can also stay warm temporarily, controlled by `readIdleTtlMs`. ## Choose what to pin Pins resolve under `pinUser`, which defaults to `undefined`. Choose queries that are independent of the current viewer, such as a public topic list. Do not use a shared pin as a substitute for per-request authorization. A request reuses the pin only with the same canonical query and visibility scope. Pins have no per-viewer visibility key. A read scoped by `subject` or `routingKey` can create a separate materialization, even with the same query arguments. Different arguments or authorization filters can also produce a separate result. The current lease path also assigns a pinned policy to any query whose **name** appears in `pinnedQueries`. That includes other argument combinations leased under that name, not only the arguments asserted at startup. Prefer a dedicated named query with fixed or tightly bounded arguments. Otherwise, user-selected values can leave many distinct results pinned. Pins consume memory and maintenance work while no one reads them. Initial materialization reads the starting data. Later work depends on the query, indexes, and affected rows. Each response also serializes the result. ## Keep pins available The daemon does not persist materialization state across restarts. Call `assertPins()` at startup and after a follower boot identifier changes. Pinned means retained with no subscribers, not durable across a restart. For a fleet, configure `pinFanout` to assert pins on all live followers. Without it, `assertPins()` uses its configured daemon connection. The [API server guide](https://rindle.sh/docs/api-server#pinned-queries-the-one-shot-read) describes the lifecycle, and the [deployment guide](https://rindle.sh/docs/deploy) covers routing. A pinned result is current for the source changes that its engine has applied. Replication lag can still separate a follower from the write authority. Use the deployment's consistency guarantees when interpreting a read. --- [View this page on Rindle](https://rindle.sh/docs/pinned-queries) --- # Refining schema types Refine generated JSON and string types in a handwritten module that survives schema regeneration. Generated schemas describe SQL column kinds and nullability. They cannot infer an application's JSON interfaces or string literal unions. `refineTable` narrows those TypeScript types. `refineSchema` replaces the table in the assembled schema. Keep both calls in a handwritten module so regeneration does not remove them. ## Start with matching SQL columns This independent example uses the [schema generation workflow](https://rindle.sh/docs/schema). Its `issue` table has these columns: ```sql CREATE TABLE issue ( id TEXT PRIMARY KEY, status TEXT NOT NULL, labels JSON NOT NULL, meta JSON NOT NULL ); ``` Apply the migration and run `rindle schema gen --out schema.gen.ts`. That file exports `issue` and `schema`; its columns use `string()` and `json()`. ## Refine the generated table ```ts // schema.ts — handwritten import { refineSchema, refineTable, json, string } from "@rindle/client"; import { schema as generatedSchema, issue as generatedIssue } from "./schema.gen.ts"; export type Label = "bug" | "feature" | "chore"; export interface Meta { spent: number; estimate: number } export const issue = refineTable(generatedIssue, { status: string<"todo" | "doing" | "done">(), labels: json(), meta: json(), }); export const schema = refineSchema(generatedSchema, { tables: [issue] }); ``` Build the query builder, backend, mutators, and fragments from this refined `schema`. Query rows then expose `labels` as `Label[]` and `meta` as `Meta`. Only refine columns that exist in your generated table. ## Keep type assumptions valid Refinements do not validate stored values. A TypeScript union does not stop a SQL writer from storing another string, and an interface does not validate JSON. Enforce those assumptions through argument validation and database constraints where appropriate. Include background writers in that policy. The helpers check runtime schema compatibility. `refineTable` rejects changing a JSON column into a string column. `refineSchema` checks the table's column kinds, primary key, and locality against the generated definition. These checks preserve the database shape; they do not prove your narrower value types. Do not cast columns inside `schema.gen.ts`. Regeneration overwrites that file. The handwritten module can also use [`extendSchema`](https://rindle.sh/docs/local-only-tables) to add local-only tables for the browser. --- [View this page on Rindle](https://rindle.sh/docs/refining-schema-types) --- # Isomorphic mutators Define shared write logic, run it as a browser prediction and server transaction, and understand replay requirements. A **mutator** describes a write, such as creating an issue or changing its status. In a synced app, a named mutator lets the browser predict that write before the server accepts it. An **isomorphic mutator** shares one implementation between the browser and API server. The two executions use different data. The browser can hold only part of the database, and the server can reject a write. Shared code keeps the write rules together. Rebase reconciles the prediction with the authoritative result. This page covers the shared mutation API used in the [synced-app quickstart](https://rindle.sh/docs/synced-app-quickstart). Direct SQL writes and embedded engine writes do not require this API. ## One body, two drivers A shared mutator is a synchronous JavaScript **generator**. Each `yield` pauses its body and returns a logical database operation, such as `tx.insert(...)`. The driver performs that operation and resumes the generator with the result. Each tier supplies its own driver: - The **browser** drives the body **synchronously** against its local wasm engine. Every affected view updates before the call returns. The prediction is **re-invoked on every rebase**, so the body must be deterministic (rules below). - The **API server** drives the same body **asynchronously** inside an authoritative transaction, rendering each yielded op to dialect SQL. See [the API server](https://rindle.sh/docs/api-server#driving-the-shared-mutators) for the wiring. Pair each body with the schema for its args at one site: `shared(args, gen)`. Bind `shared` to your schema with `defineMutators`. Then every op checks its table, column names, value types, and pk columns at compile time: ```ts // shared/app-def.ts — imported by BOTH the browser and the API server import { defineMutators } from "@rindle/client"; import type { MutationGen, MutatorCtx, Row } from "@rindle/client"; import type { ClientRegistry } from "@rindle/optimistic"; import { z } from "zod"; import { schema, issue } from "./schema.gen.ts"; const { shared } = defineMutators(schema); export const createIssueArgs = z.object({ id: z.string(), title: z.string(), status: z.string(), priority: z.string(), createdAt: z.number(), }); export type CreateIssueArgs = z.infer; // Normalization runs INSIDE the one body, so both tiers normalize identically. export function cleanTitle(t: string): string { return t.trim().slice(0, 200); } export const mutators = { createIssue: shared(createIssueArgs, function* (tx, a: CreateIssueArgs, ctx: MutatorCtx): MutationGen { const title = cleanTitle(a.title); if (!title) return; // a no-op prediction is fine yield tx.insertIgnore("user", { id: ctx.user, name: ctx.user }); yield tx.insert("issue", { id: a.id, title, status: a.status, priority: a.priority, ownerId: ctx.user, createdAt: a.createdAt, updatedAt: a.createdAt, }); }), setStatus: shared( z.object({ id: z.string(), status: z.string(), updatedAt: z.number() }), function* (tx, a): MutationGen { yield tx.update("issue", { id: a.id, status: a.status, updatedAt: a.updatedAt }); }, ), } satisfies ClientRegistry; ``` The arg schema does double duty. The **server** parses the untrusted wire args through it before the body runs — a failed parse is a hard reject. **Both** tiers derive the arg *type* from it with `z.infer`. The client trusts its typed callsites and skips the parse. ## The op vocabulary `tx` is a stateless **effect factory** — every method just *builds* an op to `yield` (it performs no I/O). Ops are keyed by column name, independent of column order: - `yield tx.insert(table, row)` — the insert shape. Nullable columns can be omitted and default to null; non-null columns are required. - `yield tx.update(table, row)` — the pk plus **only the columns that change**. A missing row is a no-op. - `yield tx.upsert(table, row)` — an insert shape. Replaces the non-pk columns on a pk conflict. - `yield tx.insertIgnore(table, row)` — an insert shape. Does nothing on a pk conflict (renders `ON CONFLICT DO NOTHING` server-side). The isomorphic twin of `if (!exists) insert`. - `yield tx.delete(table, { pk })` — pk columns only. A mutator that spans several tables `yield`s each op in turn. **Helpers** follow one convention: a multi-op (or reading) helper is itself a generator and is spread with `yield*` (`yield* applyTags(tx, a)`). A single-op helper *returns* one op and is plain-`yield`ed. Prefer returning ops for single-op helpers — a forgotten `yield` leaves an obvious dead statement, where a forgotten `yield*` on a generator is a silent no-op. ## Reads inside a mutator A read is a yield whose **expression evaluates to the result** — the one yield suspends the generator while the driver resolves it and feeds it back: ```ts closeIssue: shared( z.object({ id: z.string(), updatedAt: z.number() }), function* (tx, a, ctx): MutationGen { const current = (yield tx.row("issue", { id: a.id })) as Row | undefined; if (!current || current.ownerId !== ctx.user) return; yield tx.update("issue", { id: a.id, status: "closed", updatedAt: a.updatedAt }); }, ), ``` Add this entry to the `mutators` object above. `Row` describes the generated issue table; `tx.row` can return `undefined` when the row is absent. - `yield tx.row(table, { pk })` — a point read by primary key. - `yield tx.query(builder)` — a full ad-hoc query (`where` / `orderBy` / `limit` / joins) evaluating to its **rows** — always an array, in the query's order (a root `.one()` is not unwrapped — take `[0]`). Build it with the same `newQueryBuilder(schema)` your app-def exports. - `yield tx.all([tx.row(...), tx.row(...)])` — fan point reads out. Resolved concurrently on the server, in array order on the client, results returned in the **same order** on both tiers so the body stays deterministic. Every read sees the **current base plus this transaction's own staged writes** (read-your-writes) — on the browser engine and in the server's authoritative transaction alike. That symmetry is what makes read-dependent writes correct under rebase: the body replays the *intent* against whatever state it lands on, not a stale effect. (If you drive mutators against a Postgres authority instead of the daemon, point reads (`tx.row`) work today. Full `tx.query` support there is planned.) This has two consequences: - **Ownership checks can live in the one body.** `deleteIssue` in the [quickstart](https://rindle.sh/docs/synced-app-quickstart) reads the row and returns early for a non-owner — a no-op locally *and* in the authoritative run, where `ctx.user` is the verified principal. - **A reading mutator can't be folded** — see [high-frequency writes](#high-frequency-writes-must-be-absorbing). ## The acting principal: `ctx.user` Every shared body receives `ctx: MutatorCtx` — `{ user }`, the authenticated identity of whoever is writing — as its third argument. - The **client** injects its local user: the `user: () => currentUser()` option of [`createRindleClient`](https://rindle.sh/docs/client) (re-read per invoke, stable across a rebase re-invoke). - The **server** injects its **authenticated** principal — see `sharedCtx` in [the API server](https://rindle.sh/docs/api-server#driving-the-shared-mutators). The browser does not send `ctx.user` as a mutation argument. The server supplies its own identity from verified credentials. A development header alone does not authenticate a caller. Use `ctx.user` for the acting identity. An `owner` or `author` argument is untrusted input. A requested ownership transfer can still be an argument, subject to server authorization. ## The determinism rules A mutator body re-runs on every rebase, and the server replays it from `(name, args)` alone. So the body must be a pure function of `(args, ctx, reads)`: 1. **No `Date.now()`, no `Math.random()`, no I/O.** Generate ids and timestamps at the **callsite** and pass them in as args. 2. **No reading component or module state** — everything the body needs arrives as args, `ctx`, or a `yield`ed read. 3. **No local-only tables.** A mutator replays from `(name, args)` on the server, so it can't depend on private browser rows — use [`store.writeLocal`](https://rindle.sh/docs/client#local-only-tables-drafts-selections-prefs) for those. 4. **Normalize inside the body** (trim, clamp, default) so both tiers normalize identical inputs the same way. A helper like `cleanTitle` keeps that rule shared. If the body throws during the initial browser prediction, the call throws and does not enqueue that mutation. A throw during the authoritative server execution rolls back its transaction; rejection then removes the browser prediction. The full rejection model (hard reject vs. accepted-but-no-op) lives in [the API server](https://rindle.sh/docs/api-server#driving-the-shared-mutators). ## High-frequency writes must be absorbing `app.mutate..folded(opts, args)` collapses a run of same-key calls into one pending entry. The local view updates on every call — the server sees only the last (see [the browser client](https://rindle.sh/docs/client#folded-writes-high-frequency-drags) for the mechanics). The constraint lives with the mutator: a folded mutator must be **absorbing** — replaying only the last args must equal replaying all of them (`setScore(8)` after `setScore(5)` is just `8`). An `increment()`-style body is not absorbing and must not be folded. The folded path refuses a mutator that reads state (`yield tx.row` / `tx.query`) by throwing. ## Next steps - [The browser client](https://rindle.sh/docs/client) — how predictions apply, rebase, and snap back, plus folded-write mechanics. - [The API server](https://rindle.sh/docs/api-server#driving-the-shared-mutators) — `sharedApiMutators`, server-only authority (policy guards, the raw-SQL escape hatch), and the two rejection shapes. - [Synced-app quickstart](https://rindle.sh/docs/synced-app-quickstart) — the full `shared/app-def.ts` contract in context. - [Schema & migrations](https://rindle.sh/docs/schema) — the generated schema these ops typecheck against. - [Troubleshooting](https://rindle.sh/docs/troubleshooting) — the ways a mutator goes subtly wrong. --- [View this page on Rindle](https://rindle.sh/docs/mutators) --- # Authorizing reads & writes Scope named queries and mutators with server-derived identity, and handle writes the server rejects. This guide adds access rules to the [manual synced-app quickstart](https://rindle.sh/docs/synced-app-quickstart). It applies to the optimistic client and application API server. Other [browser clients](https://rindle.sh/docs/browser-clients) need authorization at their own server boundary. Your HTTP adapter authenticates the request. Rindle does not verify a session cookie or JWT for you. The adapter passes the verified identity as `ctx.user` to `handleQueryJson`, `handleReadJson`, or `handleMutateJson`. The quickstart's `x-user` header is only a development identity. Replace that adapter logic with your authentication system before exposing private data. ## Restrict a named query Create a query that returns only the caller's issues. This module imports the query builder defined in the quickstart: ```ts // shared/private-queries.ts import { defineQuery } from "@rindle/client"; import { z } from "zod"; import { q } from "./app-def.ts"; const args = z.object({ limit: z.number().int().min(1).max(100) }); export const myIssuesQuery = defineQuery( "myIssues", (raw) => args.parse(raw), ({ limit }, ctx: { user: string | undefined }) => { if (!ctx.user) throw new Error("Sign in to read your issues"); return q.issue.where.ownerId(ctx.user) .orderBy("createdAt", "desc").orderBy("id", "asc").limit(limit); }, ); ``` Register this query in `server/api.ts`, replacing the quickstart's public issue registry if those rows should now be private: ```ts import { myIssuesQuery } from "../shared/private-queries.ts"; const queries = registerQueries([myIssuesQuery]); ``` Keep the quickstart's `authorizeQuery` gate and pass this `queries` object to `createRindleApiServer`. Do not leave an unrestricted query registered for the same private dataset. The browser supplies its local identity when it builds the prediction: ```ts import { app } from "./rindle-client.ts"; import { myIssuesQuery } from "../shared/private-queries.ts"; const view = app.store.materialize(myIssuesQuery({ limit: 20 }, { user: "demo" })); // Use your signed-in session's user ID in place of the demo identity. // When the consumer is finished: view.destroy(); ``` Only the query name and arguments travel in this request. The server supplies its own authenticated context; it does not trust the browser's `user` value. A request gate controls whether a query can run. Its filters control which rows that query returns. Use both where required, including for server-rendered reads. Authorization checks run when a lease is created or renewed; do not treat them as a per-row callback on every streamed change. ## Enforce write rules independently A read filter does not grant or deny writes. The server must check each mutation's permissions against authoritative data. The browser may predict against an incomplete local dataset. For example, replace the quickstart's `deleteIssue` entry with this shared body: ```ts // shared/app-def.ts: add Row to the existing type imports. import type { Row } from "@rindle/client"; // Inside the mutators object, using the existing shared, z, and issue imports: deleteIssue: shared( z.object({ id: z.string() }), function* (tx, a, ctx): MutationGen { const current = (yield tx.row("issue", { id: a.id })) as Row | undefined; if (!current) return; if (current.ownerId !== ctx.user) throw new Error("Only the owner can delete this issue"); yield tx.delete("issue", { id: a.id }); }, ), ``` The quickstart's `sharedCtx` supplies authenticated `ctx.user` on the server. The browser supplies a local principal for prediction. A malicious client can change its prediction, but cannot change the identity verified by your adapter. A local throw prevents the mutation from being enqueued. A server throw rejects the authoritative mutation and rolls back its writes. A body that returns without writing is an accepted no-op. Choose the behavior your UI should report. For checks that belong only on the server, wrap or override the named mutator with an API mutator. See the complete wiring in [The API server](https://rindle.sh/docs/api-server#driving-the-shared-mutators). ## Keep authorization separate from presentation Hide unavailable actions to help users understand their permissions. Still enforce the same rules on the server. Server-only query resolvers can apply additional filters, but their security comes from the returned data being restricted, not from keeping the filter expression secret. Handle [rejected writes](https://rindle.sh/docs/rejected-writes) in the UI and keep database tokens in trusted server code. Query leases grant access to a result; they are not database-wide credentials. --- [View this page on Rindle](https://rindle.sh/docs/authorization) --- # Handling rejected writes Let the client reconcile a rejected prediction, then show the failure and a useful next action to the user. The optimistic client predicts a mutation before the server responds. The server can reject it when validation, authorization, or a database rule fails. This guide extends the [manual synced-app quickstart](https://rindle.sh/docs/synced-app-quickstart). The client reconciles predictions with authoritative state. Your application reports the failure and preserves any input the user needs to correct. ## Distinguish rejection from a failed request The mutation API returns an outcome for each envelope. An outcome with `accepted: false` is a final rejection. Causes include invalid arguments, an `authorizeMutation` denial, a thrown server guard, or a deterministic database constraint violation. A failed HTTP request or unavailable authority is different. The mutation queue retries failed batches with backoff and reports them through `onMutationError`. For example, an HTTP authentication failure may require the application to restore its session before a retry can succeed. Do not report every network failure as a permanent rejection. If the initial browser prediction throws, the call itself fails and no mutation is enqueued. Catch that error at the action that invoked the mutator. ## Show the rejection Add these callbacks to the quickstart's existing `createRindleClient` options: ```ts onRejected: (envelope, reason) => { window.alert(`${envelope.name} was rejected: ${reason}`); }, onMutationError: (error, attempt) => { console.error(`Mutation delivery failed, attempt ${attempt}`, error); }, ``` `envelope` identifies the attempted write with its client ID, mutation ID, name, and arguments. Use those arguments to restore a form, or associate the failure with an item in your application. The HTTP outcome and the confirming subscription are separate messages. `onRejected` reports the outcome; do not assume the UI has already received the confirming stream update when this callback runs. The client removes the rejected prediction as it reconciles with that stream. Do not manually reverse the rows. For a real UI, replace the alert with an error message or notification. A retry is a new user action through the named mutator, subject to the same access rules. ## An accepted no-op is different A mutator can return without writing. The server accepts that mutation and advances its confirmation position; `onRejected` does not fire. The browser and server can read different data. A body might predict a write locally and then do nothing on the server. Reconciliation corrects that prediction even though the server accepted the mutation. Use a thrown error when the user needs an explicit failure reason. The [create-rindle starter](https://rindle.sh/docs/create-rindle) includes a rejection demonstration: a server guard rejects a message containing `spam`, and the browser displays a notification. See [Authorizing reads and writes](https://rindle.sh/docs/authorization) for application access rules and [The API server](https://rindle.sh/docs/api-server#driving-the-shared-mutators) for server-only mutation guards. --- [View this page on Rindle](https://rindle.sh/docs/rejected-writes) --- # Folded mutations Coalesce repeated optimistic writes from typing, sliders, and dragging before sending them to the server. A folded mutation applies every call as a local prediction, but combines pending calls before sending them to the server. Use it for typing, sliders, or dragging when only the latest value matters. This guide extends the [manual synced-app quickstart](https://rindle.sh/docs/synced-app-quickstart). It uses that app's issue schema, shared mutator registry, and browser client. ## Define a mutation that sets the final value Add this entry to `mutators` in `shared/app-def.ts`. The quickstart already defines `shared`, imports `z`, and imports the `MutationGen` type: ```ts renameIssue: shared( z.object({ id: z.string(), title: z.string(), updatedAt: z.number() }), function* (tx, a): MutationGen { yield tx.update("issue", { id: a.id, title: a.title, updatedAt: a.updatedAt, }); }, ), ``` This is an **absorbing** write: applying only the last arguments has the same effect as applying every call in order. It sets columns from its arguments and does not read state. Do not fold increments, append operations, or writes whose result depends on intermediate values. The folded path rejects reads through `tx.row` or `tx.query`. Server authorization still applies to the mutation that the client sends. ## Fold calls from the editor ```ts // src/rename-issue.ts import { app } from "./rindle-client.ts"; export function previewTitle(id: string, title: string) { return app.mutate.renameIssue.folded( { key: id, debounceMs: 120, maxWaitMs: 1000 }, { id, title, updatedAt: Date.now() }, ); } const pending = previewTitle("issue-42", "Updated title"); // For example, force the last preview to send when the editor loses focus: pending.flush(); const mid = await pending.mid; console.log("Assigned mutation ID", mid); ``` The fold identity combines the mutator name with `key`. Use the row's primary key when each row has an independent edit stream. Every call updates the local view immediately. A trailing debounce sends the latest arguments after an idle gap. `debounceMs` defaults to 120 ms. `maxWaitMs` is optional; without it, continuous input can keep delaying the flush. With the example's threshold, a call made at least one second into the window flushes it immediately. This check runs on calls, so it is not a timer deadline: if input stops first, the trailing debounce can flush later. A long edit can send multiple server writes. `flush()` sends the pending window now. `mid` resolves when that window receives its wire mutation ID. It does **not** indicate server acceptance or confirmation. Use the client's [pending and rejection signals](https://rindle.sh/docs/client) to show write status. ## Finish a gesture `app.flushFolds()` flushes all outstanding windows. The client also hooks page lifecycle events, but a page closing is not a durable delivery guarantee. A normal call to `app.mutate.renameIssue(args)` is useful when an explicit final action should be a separate mutation. By default, overlapping ordinary writes flush earlier folds so their order remains meaningful. Keep [undo history](https://rindle.sh/docs/undo-redo) at the gesture level. Record the value before the edit and its final value, rather than every preview frame. --- [View this page on Rindle](https://rindle.sh/docs/folded-mutations) --- # Undo / redo Implement undo and redo as inverse named mutations that use the normal optimistic write protocol. Rindle does not provide an application undo stack. You can build one by recording a command and an inverse command, then sending both through the same named mutators as ordinary edits. This guide extends the [manual synced-app quickstart](https://rindle.sh/docs/synced-app-quickstart). Its example changes an issue's status using the existing `setStatus` mutator. ## Keep a command history ```ts // src/history.ts interface Command { label: string; do(): void; undo(): void; } export function createHistory() { const stack: Command[] = []; let cursor = 0; return { run(command: Command) { command.do(); stack.splice(cursor); stack.push(command); cursor++; }, undo() { const command = stack[cursor - 1]; if (!command) return; command.undo(); cursor--; }, redo() { const command = stack[cursor]; if (!command) return; command.do(); cursor++; }, }; } ``` Capture the old and new values when the user makes an edit: ```ts // src/status-history.ts import { app } from "./rindle-client.ts"; import { createHistory } from "./history.ts"; export const history = createHistory(); export function changeStatus(id: string, previous: string, next: string) { if (previous === next) return; const set = (status: string) => { app.mutate.setStatus({ id, status, updatedAt: Date.now() }); }; history.run({ label: "Change issue status", do: () => set(next), undo: () => set(previous), }); } ``` Call `changeStatus` with the row's current status and the requested status. Bind `history.undo()` and `history.redo()` to your UI or keyboard handlers. Each action generates its timestamp before invoking the mutator. The shared mutator receives that fixed argument on the browser, server, and later rebases. Do not read mutable component state from a saved command to recover its old value. ## Decide what an inverse means Undo sends a new authoritative mutation. It is not a private rewind of a view, and collaborators receive its effects. The client predicts and reconciles it like any other write. A saved old value can overwrite a collaborator's later edit. Rindle does not infer which edits an undo should preserve. Enforce any preconditions in your mutator and decide how your history UI handles [rejections](https://rindle.sh/docs/rejected-writes). The stack above changes its cursor after a synchronous call succeeds. A later server rejection still needs application handling. It also does not persist history, notify React, group gestures, or limit memory use. Deletion and external side effects need their own inverse behavior. For example, restoring a deleted row may require its old column values and related rows. For [folded edits](https://rindle.sh/docs/folded-mutations), record one command per completed gesture. --- [View this page on Rindle](https://rindle.sh/docs/undo-redo) --- # TanStack Start Connect Rindle to TanStack Start with a provider, route loaders, and optional server preloads. `@rindle/tanstack` connects named Rindle queries to TanStack Router and TanStack Start. It supplies two objects: a route `loader` and a React `Provider`. The loader prepares route data. The provider moves the initial server-rendered results into the live browser client. This guide continues the public issue-list example in [Server rendering](https://rindle.sh/docs/ssr). That page defines the SQL table, named query, API factory, preloader, browser boot function, and list component. This page supplies their TanStack integration. For a new application with this wiring already installed, use [`create-rindle`](https://rindle.sh/docs/create-rindle). The generated chat example has different tables and queries, but uses the same adapter. ## Before you start Use an existing TanStack Start project with its Vite plugin, router, and generated route tree. Install the adapter: ```bash pnpm add @rindle/tanstack ``` The following modules come from the SSR example: | Module | Exports | Purpose | | --- | --- | --- | | `shared/schema.gen.ts` | `schema` | Generated table types | | `shared/queries.ts` | `recentIssuesQuery` | The named query shared by both tiers | | `server/ssr-api.ts` | `createReadApi` | Public query authority and trusted data-tier connection | | `server/preload.ts` | `preloadQueries` | One-shot server reads converted to a seed | | `src/rindle-client.ts` | `bootClient` | Memoized browser-only client startup | | `src/IssueList.tsx` | `IssueList` | The component that reads the live query | All six modules are defined in the [SSR guide](https://rindle.sh/docs/ssr#1-define-the-example-data-and-query). They are application files, not exports from `@rindle/tanstack`. ## Create the integration Create one adapter that shares the browser client between route loaders and the provider: ```ts // src/rindle-tanstack.ts import { createRindleTanStack } from "@rindle/tanstack"; import { schema } from "../shared/schema.gen.ts"; import { bootClient } from "./rindle-client.ts"; export const rindle = createRindleTanStack({ schema, boot: bootClient, preload: async (queries) => { if (!import.meta.env.SSR) return {}; const { preloadQueries } = await import("../server/preload.ts"); return preloadQueries(queries); }, }); ``` The adapter calls `preload` only from its server branch. Vite's static SSR guard also keeps the authority module and its credentials out of the browser bundle. The adapter calls `boot` only in the browser and memoizes its promise. ## Mount the root provider In the root document, put `rindle.Provider` around the route outlet. The following is a complete minimal root route. Preserve any existing head configuration or layout from your application: ```tsx // src/routes/__root.tsx import { createRootRoute, HeadContent, Outlet, Scripts } from "@tanstack/react-router"; import { rindle } from "../rindle-tanstack.ts"; export const Route = createRootRoute({ shellComponent: RootDocument, }); function RootDocument() { return ( ); } ``` The provider combines the `rindle` loader-data fields from all matched routes. If two routes supply the same seed key, the later matched route wins. It passes that combined state to `RindleSSR`, so you do not also mount `SsrApp` from the framework-neutral SSR example. ## Declare the route's query The home route prepares the same query that `IssueList` reads: ```tsx // src/routes/index.tsx import { createFileRoute } from "@tanstack/react-router"; import { recentIssuesQuery } from "../../shared/queries.ts"; import { IssueList } from "../IssueList.tsx"; import { rindle } from "../rindle-tanstack.ts"; export const Route = createFileRoute("/")({ loader: rindle.loader({ query: () => recentIssuesQuery(), }), component: IssueList, }); ``` On the server, the loader preloads the query and returns `{ rindle: seed }`. On browser navigation, it calls the shared client's `ensure` method and returns an empty seed. Live subscriptions own browser data after hydration. The default readiness policy is `until: "present"`: existing local rows can let navigation finish before the server confirms the query. Use `until: "complete"` when the route must wait for its server result. A confirmed empty result satisfies both policies. See [Preload and navigate](https://rindle.sh/docs/preloads). ## Expose the public query endpoint The SSR preloader calls the authority in-process. The browser still needs an HTTP endpoint to request its live-query lease. Add this route: ```tsx // src/routes/api.rindle.query.tsx import { createFileRoute } from "@tanstack/react-router"; export const Route = createFileRoute("/api/rindle/query")({ server: { handlers: { POST: async ({ request }) => { try { const { createReadApi } = await import("../../server/ssr-api.ts"); const body = await request.json(); const api = createReadApi(); try { const result = await api.handleQueryJson(body, { user: undefined, // Matches the public SSR read. request, }); return Response.json(result); } finally { api.close(); } } catch (error) { const { RindleApiError } = await import("@rindle/api-server"); const status = error instanceof RindleApiError ? error.status : error instanceof SyntaxError ? 400 : 500; console.error(error); return Response.json({ error: "Query request failed" }, { status }); } }, }, }, }); ``` Keep the server-only imports inside the handler. TanStack Start removes the handler from the browser route module. If TypeScript does not recognize the `server` route option, include this declaration in your project: ```ts // src/tanstack-start.d.ts import type {} from "@tanstack/react-start"; ``` Start the application with the `rindle dev` command from the SSR guide. Open `/` to read the seeded list. A SQL change to `issue` then updates the mounted list through its live subscription. This read-only example needs no mutation route. An app with optimistic writes also mounts `handleMutateJson` and configures its mutator registry. An HTTP one-shot read endpoint is optional when SSR calls `handleReadJson` in-process. ## Identity and routing context A loader's `query` factory runs **before** the adapter awaits browser startup. If query construction depends on a user identity, resolve that identity before the factory runs. Do not rely on `bootClient` to initialize it later. The `preload` callback receives `(queries, loaderContext)`. The adapter forwards that context without authenticating it or extracting a request. For private data, your framework integration must provide the verified request context to the [server preloader](https://rindle.sh/docs/ssr#private-data-and-request-identity). The default `RindleLoaderContext` describes `params`, `deps`, `context`, `location`, `abortController`, `preload`, and `cause`. Applications can supply a compatible, more specific context type to `rindle.loader(...)`. ## Next steps - [Preload and navigate](https://rindle.sh/docs/preloads) — multiple queries, server-only extras, readiness, and cancellation. - [Server rendering](https://rindle.sh/docs/ssr) — seed lifetime, read failures, and private data. - [The browser client](https://rindle.sh/docs/client) — mutations and client cleanup. --- [View this page on Rindle](https://rindle.sh/docs/tanstack) --- # Preload & navigate Preload named queries during navigation and reuse available local rows while server subscriptions load. Preloading starts a named query before the component that needs it mounts. The browser can receive its rows during a route transition or a link hover. The destination component then reads the query through the usual Rindle hooks. Two related APIs serve different stages: | API | Where it runs | What it prepares | | --- | --- | --- | | `app.ensure(query, options)` | Browser | A live named query and a temporary retain | | `createServerStore(...).preload(...)` | Server | A one-shot snapshot for the initial HTML | The [TanStack adapter](https://rindle.sh/docs/tanstack) combines these paths in a route loader. This page first explains browser readiness, then shows the route options. For server snapshots and their lifetime, read [Server rendering](https://rindle.sh/docs/ssr). ## Define a query to preload This example uses the `issue` table from the [SSR guide](https://rindle.sh/docs/ssr#1-define-the-example-data-and-query): a numeric `id` and a string `title`. Add this query module: ```ts // shared/issue-detail.ts import { defineQuery, newQueryBuilder } from "@rindle/client"; import { schema } from "./schema.gen.ts"; const q = newQueryBuilder(schema); export const issueByIdQuery = defineQuery( "issueById", (raw): number => { if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw < 1) { throw new Error("Expected a positive integer issue ID"); } return raw; }, (id) => q.issue.where.id(id).one(), ); ``` Register this query on the API server alongside `recentIssuesQuery`. In the SSR example's `server/ssr-api.ts`, replace the `queries` option with: ```ts // Add this import to server/ssr-api.ts: import { issueByIdQuery } from "../shared/issue-detail.ts"; // Inside createRindleApiServer({ ... }): queries: registerQueries([recentIssuesQuery, issueByIdQuery]), ``` This is a modification to the [defined API factory](https://rindle.sh/docs/ssr#2-define-the-server-authority), not a standalone module. The server must recognize every preloaded name. An ad-hoc local builder query has no remote identity, so `ensure` rejects it. ## Choose when the browser can continue The client exposes `ensure` through the object returned by `createRindleClient`. It resolves to `void`. It prepares the query rather than returning its rows. ```ts // src/prepare-issue.ts import { bootClient } from "./rindle-client.ts"; import { issueByIdQuery } from "../shared/issue-detail.ts"; export async function prepareIssue(id: number, signal?: AbortSignal) { const app = await bootClient(); await app.ensure(issueByIdQuery(id), { until: "present", signal, }); } ``` `bootClient` is the [browser startup function](https://rindle.sh/docs/ssr#4-define-browser-only-startup) defined in the SSR guide. Existing synced apps can use their client instance directly. | Policy | When the promise resolves | What the result means | | --- | --- | --- | | `"complete"` | The server marks the query complete | This query received its authoritative result, which can be empty | | `"present"` | The local view has a result, or the server marks it complete | Available rows can be partial, stale, or optimistic | For a plural query, a local result means a nonempty array. For `.one()`, it means a non-null row. An empty local view alone does not satisfy `"present"`, because the server can still have matching rows. A confirmed empty result satisfies both policies. **The defaults differ:** `app.ensure(query)` defaults to `"complete"`. `rindle.loader({ query })` defaults to `"present"`. Use `"present"` when available content makes a useful navigation result. Use `"complete"` when the page needs the server's answer before it decides what to show. Completeness describes that query's server channel, not an absence of pending optimistic writes or replication lag. Resolving a `"present"` wait does not change the query's status to `"complete"`. The destination can display local rows while `useQueryStatus` still reports `"unknown"`. ## Declare client and server route data The following route uses the [adapter instance](https://rindle.sh/docs/tanstack#create-the-integration) named `rindle`. It waits for the selected issue during browser navigation. It also preloads the recent-issue list for the initial server render: ```tsx // src/routes/issues.$id.tsx import { createFileRoute } from "@tanstack/react-router"; import { useQuery, useQueryStatus } from "@rindle/react"; import { issueByIdQuery } from "../../shared/issue-detail.ts"; import { IssueList } from "../IssueList.tsx"; import { recentIssuesQuery } from "../../shared/queries.ts"; import { rindle } from "../rindle-tanstack.ts"; export const Route = createFileRoute("/issues/$id")({ loader: rindle.loader({ query: ({ params }) => issueByIdQuery(Number(params.id)), ssr: () => recentIssuesQuery(), until: "present", }), component: IssuePage, }); function IssuePage() { const { id } = Route.useParams(); const query = issueByIdQuery(Number(id)); const issue = useQuery(query); const status = useQueryStatus(query); return (
    {issue ?

    {issue.title}

    :

    {status === "complete" ? "Issue not found." : "Loading issue…"}

    }
    ); } ``` | Loader option | Server render | Browser navigation | | --- | --- | --- | | `query` | Included in the seed | Retained and awaited with `ensure` | | `ssr` | Included in the seed | Ignored by the loader | | `until` | Does not change the server read | Selects readiness for `query` | Here, `IssueList` receives seeded data on the first page load. On later browser navigation, its own hook starts the query when it mounts. The `ssr` declaration does not preload that query during client navigation. A loader can return arrays from either factory. For example, replace its `query` option with this to wait for both views on browser navigation: ```ts query: ({ params }) => [ issueByIdQuery(Number(params.id)), recentIssuesQuery(), ], ``` This is a route-option fragment. The adapter preloads the union of `query` and `ssr` on the server, with duplicate query identities removed. In the browser, it waits for all `query` results concurrently. At least one factory is required. ## Cancel a wait or set a deadline TanStack loaders pass their `abortController.signal` to `ensure`. With the client API directly, pass your own signal. For example, this helper limits the wait to ten seconds: ```ts // src/prepare-with-deadline.ts import { prepareIssue } from "./prepare-issue.ts"; export async function prepareIssueWithDeadline(id: number) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 10_000); try { await prepareIssue(id, controller.signal); } finally { clearTimeout(timer); } } ``` `ensure` has no built-in timeout. Aborting rejects this caller's wait with an `AbortError`. It does not cancel a shared subscription or another caller's wait. Route-level error handling decides what to show after a rejected wait. ## Retention and cleanup Concurrent waits for the same name, arguments, and local query definition share a preload entry. They can use different readiness policies. The client normally keeps a completed preload for another ten seconds. This gives the destination time to acquire its own subscription. A `"present"` result normally remains retained while server confirmation is pending. The cache also limits retained entries to 32, evicting entries without active waiters when needed. These are preload-cache defaults, not guarantees that every visited query stays in memory. Components own their subscriptions after mounting. Closing the client releases preloads and rejects unfinished waits. ## Next steps - [TanStack Start](https://rindle.sh/docs/tanstack) — provider, route wiring, and the query endpoint. - [Server rendering](https://rindle.sh/docs/ssr) — initial snapshots and authenticated server reads. - [The browser client](https://rindle.sh/docs/client) — query status, pending writes, and local data. --- [View this page on Rindle](https://rindle.sh/docs/preloads) --- # Server rendering Read named queries on the server, seed the rendered page, and hand the browser over to live subscriptions. Server-side rendering (SSR) lets the server put query results in the first HTML response. The browser displays those results before its WebAssembly engine starts. After hydration, the same components read from the live browser store. This guide builds the Rindle modules for a small, public issue list. It is an independent example with its own two-column table, separate from the scaffold and manual quickstart. It defines every helper used here. Your React framework supplies the HTTP server, HTML document, and transport for loader data. The [TanStack Start guide](https://rindle.sh/docs/tanstack) connects these exact modules to routes and a root document. SSR is optional. A browser-only application can follow the [synced-app quickstart](https://rindle.sh/docs/synced-app-quickstart) without these modules. ## What happens during a page load | Stage | What Rindle does | | --- | --- | | Server loader | Reads the named query through your API authority, then creates a serializable snapshot | | Server render | React reads the snapshot without starting an engine or subscription | | Browser hydration | React reads the same snapshot, so its initial markup matches the server | | Live browser | Starts the browser client, opens subscriptions, and replaces the snapshot with live query data | The serialized snapshot is called a **seed**. Creating it is **dehydration**. Restoring it is **hydration**. A seed contains projected query results, not a copy of the browser's normalized tables or its pending mutations. A seed does not replace the live subscription. The browser still receives its initial subscription data and later changes. A one-shot server read can leave a query warm on the daemon, but reuse depends on routing and visibility scope. ## 1. Define the example data and query Use a Rindle data tier with live-query support and a Vite-based React SSR project. Install the packages used on this page: ```bash pnpm add @rindle/client @rindle/optimistic @rindle/wasm @rindle/react @rindle/api-server pnpm add -D @rindle/cli ``` If the project has no `rindle.ncl`, create its local data-tier configuration: ```bash pnpm exec rindle init ``` Create a migration for this example: ```sql -- migrations/0001_init.sql CREATE TABLE issue ( id INTEGER PRIMARY KEY, title TEXT NOT NULL ); ``` Apply migrations and generate the TypeScript schema through your development command. For an existing Vite project, the command is: ```bash pnpm exec rindle dev --migrate --gen shared/schema.gen.ts -- vite dev ``` This command supplies the server's `RINDLE_URL` and `RINDLE_DATABASE_TOKEN`. The [CLI guide](https://rindle.sh/docs/rindle-cli) explains local process management. The generated file contains the following table definition. The generator owns this file: ```ts // shared/schema.gen.ts — generated from the SQL migration import { createSchema, number, string, table } from "@rindle/client"; export const issue = table("issue") .columns({ id: number(), title: string() }) .primaryKey("id"); export const schema = createSchema({ tables: [issue] }); ``` Define a named query in a module that both server and browser can import: ```ts // shared/queries.ts import { defineQuery, newQueryBuilder } from "@rindle/client"; import { schema } from "./schema.gen.ts"; const q = newQueryBuilder(schema); export const recentIssuesQuery = defineQuery( "recentIssues", () => q.issue.orderBy("id", "desc").limit(50), ); ``` The server resolves the name `recentIssues` to this definition. The browser uses the same definition to identify its local view and request a live subscription. This example has no mutation API. Populate or change the table through your SQL client to observe live updates. ## 2. Define the server authority Create this **server-only** factory. `createReadApi` is application code defined here, not a Rindle export: ```ts // server/ssr-api.ts import { createRindleApiServer, registerQueries } from "@rindle/api-server"; import { recentIssuesQuery } from "../shared/queries.ts"; export function createReadApi() { return createRindleApiServer({ rindle: {}, // Uses RINDLE_URL and RINDLE_DATABASE_TOKEN from the server environment. queries: registerQueries([recentIssuesQuery]), authorizeQuery: () => true, // This example's issue list is public. authorizeMutation: () => false, }); } ``` `rindle: {}` creates the trusted database connections from the server environment. The database token never belongs in loader data or browser code. The API exposes different read operations: - `handleReadJson({ name, args }, context)` returns query rows once. SSR uses this operation. - `handleQueryJson({ name, args, ... }, context)` grants a query lease. The live browser uses this operation before subscribing. Both operations apply query authorization and resolve the query on the server. The [TanStack guide](https://rindle.sh/docs/tanstack#expose-the-public-query-endpoint) mounts the lease handler for this example. Without that endpoint, the page can have an SSR seed but cannot establish its live subscription. ## 3. Read queries before rendering Define a preloader that creates a fresh server store for each call: ```ts // server/preload.ts import { createServerStore } from "@rindle/client"; import type { AnyQuery, OneShotResult } from "@rindle/client"; import { schema } from "../shared/schema.gen.ts"; import { createReadApi } from "./ssr-api.ts"; export async function preloadQueries(queries: readonly AnyQuery[]) { const api = createReadApi(); try { const server = createServerStore(schema, { query: async ({ name, args }) => { if (name === undefined) throw new Error("SSR requires a named query"); const result = await api.handleReadJson( { name, args }, { user: undefined, request: undefined }, ); // The API returns assembled rows; OneShotResult narrows their cell types. return result as OneShotResult; }, }); return await server.preloadAll([...queries], { onError: (query, error) => console.error("SSR preload failed", query.name, error), }); } finally { api.close(); } } ``` `createServerStore` supplies a read-only store for SSR. It does not start a WebAssembly engine or open subscriptions. Its injected `query` function performs the actual reads. Calling the API in-process avoids an HTTP request back into your app. The API still contacts the data tier. This factory creates an API instance for each preload call. The `finally` block closes its database connection after all reads finish. `preloadAll` reads the supplied queries concurrently and returns a `DehydratedState`. Each entry contains projected rows and a commit watermark, keyed by the local query definition. The library handles this representation. Pass it through your framework's loader-data serializer without modifying it. If a read fails, `preloadAll` omits that query's seed and calls `onError`. Other queries can still render. Without `onError`, these read failures are silent. For a page that must fail when a query fails, call `server.preload(query)` and then `server.dehydrate()` instead. `preload` propagates the error. ## 4. Define browser-only startup Create one client for the page's lifetime. `bootClient` is application code that memoizes its startup promise: ```ts // src/rindle-client.ts import { schema } from "../shared/schema.gen.ts"; async function startClient() { if (typeof window === "undefined") { throw new Error("The live Rindle client must start in the browser"); } const [{ createRindleClient }, { initWasm }, { default: wasmUrl }] = await Promise.all([ import("@rindle/optimistic"), import("@rindle/wasm"), import("@rindle/wasm/pkg/rindle_bg.wasm?url"), ]); await initWasm(wasmUrl); return createRindleClient({ schema, mutators: {}, // This example only reads public data. api: { url: "" }, // Same-origin /api/rindle/query. }); } let clientPromise: ReturnType | undefined; export function bootClient() { return clientPromise ??= startClient(); } ``` Vite's `?url` import supplies the WebAssembly asset URL. Keep Vite's client type reference in your project so TypeScript recognizes asset imports. The dynamic imports run only when `bootClient` starts in the browser. Importing this module during SSR does not construct the engine. The memoized promise also lets route loaders and the provider share the same client. ## 5. Render the same component on both sides The component uses the same hooks before and after the store transition: ```tsx // src/IssueList.tsx import { useQuery, useQueryStatus } from "@rindle/react"; import { recentIssuesQuery } from "../shared/queries.ts"; export function IssueList() { const query = recentIssuesQuery(); const rows = useQuery(query); const status = useQueryStatus(query); if (rows.length === 0) { return

    {status === "complete" ? "No issues yet." : "Loading issues…"}

    ; } return
      {rows.map((issue) =>
    • {issue.title}
    • )}
    ; } ``` For a React SSR framework without the TanStack adapter, wrap it in `RindleSSR`: ```tsx // src/SsrApp.tsx import { RindleSSR } from "@rindle/react"; import type { DehydratedState } from "@rindle/client"; import { schema } from "../shared/schema.gen.ts"; import { bootClient } from "./rindle-client.ts"; import { IssueList } from "./IssueList.tsx"; export function SsrApp({ ssrState }: { ssrState: DehydratedState }) { return ( ); } ``` Your framework's server loader calls `preloadQueries([recentIssuesQuery()])` and passes the returned value as `ssrState`. It must pass the same value to the browser's first render. Use the framework's serialization support rather than inserting raw JSON into a script. `RindleSSR` renders from a transport-free seed store on the server and during browser hydration. After hydration, its effect calls `bootClient`, seeds the live store, and switches providers. The mounted query then acquires its live subscription. A named view keeps its seed while the live client catches up. Its first server-confirmed snapshot replaces the seed, including a confirmed empty result. Changing `ssrState` later is not a general store-update API. Live subscriptions own subsequent updates. `RindleSSR` does not close the returned client when it unmounts. Your application owns that client and must call `close()` when it permanently disposes it. The component also has no built-in UI for a rejected boot promise. Startup error reporting and retry belong to the application's client lifecycle. ## Private data and request identity The working example is public: both SSR and live queries use anonymous access. Passing a user value alone does not make a public query private. For private data, your server must authenticate the incoming request before the preload. Supply that verified identity and request to `handleReadJson` through its `ApiContext`. Use the same identity model and access rules for the browser's lease requests. The browser sends session credentials. The server verifies them independently. Keep these boundaries explicit: - Create a server store per request. Never share a seed between users. - Filter private queries by the authenticated identity and enforce query authorization. - Use matching query arguments and local query context for SSR and browser hydration. - Forward the request when authentication, tenant rules, or routing read its cookies or headers. - Exclude private seed responses from shared public caches. The TanStack adapter forwards its loader context to `preload`. It does not extract a user from that context or authenticate a request. The [authorization guide](https://rindle.sh/docs/authorization) covers the query and mutation rules. ## Next steps - [TanStack Start](https://rindle.sh/docs/tanstack) — mount these modules in routes and the root document. - [Preload and navigate](https://rindle.sh/docs/preloads) — choose what client navigation waits for. - [The browser client](https://rindle.sh/docs/client) — add optimistic writes to the read-only example. --- [View this page on Rindle](https://rindle.sh/docs/ssr) --- # Local-only tables Keep drafts, selections, and preferences in reactive client tables that do not sync to the server. Local-only tables hold drafts, selections, and preferences beside a synced client's server data. They use the same query engine, but Rindle does not send them to the server. Server confirmations do not rewind their contents. This guide extends the [manual synced-app quickstart](https://rindle.sh/docs/synced-app-quickstart). A [standalone browser store](https://rindle.sh/docs/wasm-client) already keeps all its rows local. Local-only tables are temporary unless you enable [persistence](https://rindle.sh/docs/persisting-local-tables). ## Add a client schema Keep the generated SQL schema unchanged. Create a separate module for local tables: ```ts // shared/client-schema.ts import { extendSchema, string, table } from "@rindle/client"; import { schema } from "./schema.gen.ts"; export const draft = table("draft", { local: true }) .columns({ id: string(), issueId: string(), body: string() }) .primaryKey("id"); export const clientSchema = extendSchema(schema, { tables: [draft] }); ``` Use this schema in the quickstart's browser client: ```ts // src/rindle-client.ts import { createRindleClient } from "@rindle/optimistic"; import { mutators } from "../shared/app-def.ts"; import { clientSchema } from "../shared/client-schema.ts"; const currentUser = () => "demo"; export const app = await createRindleClient({ schema: clientSchema, mutators, user: currentUser, api: { url: "", headers: () => ({ "x-user": currentUser() }) }, onRejected: (envelope, reason) => window.alert(`${envelope.name}: ${reason}`), }); ``` The `x-user` header is the quickstart's development identity. A deployed app must use its authenticated session. The API server and named queries continue to import the generated `schema`; `extendSchema` accepts only local tables. ## Read and write a draft This helper owns one live query. It inserts the first draft and edits later values: ```ts // src/drafts.ts import { app } from "./rindle-client.ts"; export function openDraft(issueId: string) { const view = app.store.query.draft.where.id(issueId).materialize(); return { view, setBody(body: string) { const previous = view.data[0]; return app.store.writeLocal((tx) => { const next = { id: issueId, issueId, body }; if (previous) tx.edit("draft", previous, next); else tx.add("draft", next); }); }, close() { view.destroy(); }, }; } const draft = openDraft("issue-42"); const unsubscribe = draft.view.subscribe(() => { console.log(draft.view.data[0]?.body ?? ""); }); await draft.setBody("A reply in progress"); // When this consumer is finished: unsubscribe(); draft.close(); ``` `view.data` holds the current result. Use a subscription or a [React hook](https://rindle.sh/docs/fine-grained-reactivity) to update a UI when that result changes. Destroy a materialized view when its owner is finished. `writeLocal` resolves after the local write. It does not wait for IndexedDB storage or send an authoritative mutation. ## Keep local state outside the mutation contract - `writeLocal` accepts only local tables. Use a named mutator for synced tables. - A mutator cannot read or write local tables. Its server execution and rebase must not depend on private browser state. - A named server query cannot reference a local table. Local queries can combine local rows with synced rows already available in the client. ## Choose a lifetime | Declaration | Without persistence | With `persistLocal` | | --- | --- | --- | | `{ local: true }` | Memory for this client | IndexedDB and coordination across tabs | | `{ local: "session" }` | Memory for this client | Still ephemeral and per-tab | Use `local: "session"` for a transient selection. Use `local: true` for a draft that you may want to restore. Neither declaration sends its rows to the server. See [Persisting local tables](https://rindle.sh/docs/persisting-local-tables) for storage identity, restore behavior, and cleanup on logout. --- [View this page on Rindle](https://rindle.sh/docs/local-only-tables) --- # Persisting local tables Persist local-only client tables in IndexedDB, restore them after reloads, and coordinate updates across tabs. The optimistic client's `persistLocal` option stores eligible local tables in IndexedDB. It restores them during startup and shares updates between tabs with the same origin and storage identity. This guide continues [Local-only tables](https://rindle.sh/docs/local-only-tables). It persists local tables such as drafts. It does not persist the synced dataset or make server queries available offline. ## Enable storage Add `persistLocal` to that guide's `src/rindle-client.ts` configuration: ```ts export const app = await createRindleClient({ schema: clientSchema, mutators, user: currentUser, api: { url: "", headers: () => ({ "x-user": currentUser() }) }, persistLocal: { user: currentUser(), onError: (error) => console.error("Local persistence failed", error), }, onRejected: (envelope, reason) => window.alert(`${envelope.name}: ${reason}`), }); ``` The imports and development identity come from the preceding guide. With storage available, `createRindleClient` waits for the initial restore before it returns. Your first local query can therefore read restored drafts. All `{ local: true }` tables participate. Tables declared with `{ local: "session" }` remain ephemeral and per-tab. ## Use a stable storage identity `persistLocal.user` selects one IndexedDB database per origin and user string. It stays fixed for that client's lifetime. It is separate from the authenticated principal that authorizes server reads and mutations. Close and recreate the client when the signed-in user changes. Choose anonymous storage deliberately: all clients using the same anonymous identity share its local data on that origin. ## Understand the durability boundary A local write updates the engine before asynchronous storage completes. Awaiting `store.writeLocal` does not acknowledge a durable disk write. A crash can lose a recent update, and the browser can evict storage. The persistence layer uses IndexedDB, BroadcastChannel, and Web Locks to coordinate tabs. One tab sequences durable writes; other tabs forward and mirror updates. Storage failures can report through `onError`. Missing browser APIs can instead produce console warnings and disable persistence or cross-tab coordination. Local writes can continue without durable storage. `requestPersistentStorage: true` also requests the browser's persistent-storage permission. The browser controls whether it grants the request; this option does not turn local drafts into a server backup. A changed local schema hash resets the stored local dataset. There is no application migration callback in this option. Plan an explicit export or other migration path if local data must survive schema changes. ## Clear data on logout Closing a client does not delete its stored rows. If your logout policy requires removal, close the client and then delete its local database: ```ts import { deleteLocalPersistence } from "@rindle/optimistic"; import { app } from "./rindle-client.ts"; export async function clearLocalData(storageUser: string) { app.close(); await deleteLocalPersistence(storageUser); } ``` Pass the same string used for `persistLocal.user`. Coordinate logout across your application's tabs so another live client does not continue using that identity. --- [View this page on Rindle](https://rindle.sh/docs/persisting-local-tables) --- # Fine-grained reactivity Use fragment references and per-row reads to limit React updates to the components whose data changes. Fine-grained reactivity means that a component subscribes to the data it renders. An edit to one comment can update that comment's component without updating the issue card. This reduces work for large lists and editors. This guide continues the runnable [fragment example](https://rindle.sh/docs/fragments). It uses that example's `shared/fragments.ts`, `src/local-store.ts`, and `src/IssueCards.tsx`. Complete that example first. ## Separate list membership from row fields The example has three local reads: | Component | Data it reads | | --- | --- | | `IssueCards` | The ordered list of issue references | | `IssueCard` | One issue's title and ordered comment references | | `CommentView` | One comment's ID and body | `CommentFragment` selects `id` and `body`. `IssueCardFragment` includes that fragment through `.sub()`. The parent receives comment references, so a comment body edit does not change its comment list. The root query includes all required fields for synchronization. The child readers then open narrower local views. They retain the same named root query, rather than opening one remote subscription per comment. ## Keep existing children stable after list changes When a comment is inserted, the parent must render its new list. React also renders its children by default. Wrap `CommentView` in `memo` to skip existing children whose props remain unchanged. In `src/IssueCards.tsx`, add the React import and replace `CommentView` with this definition. The other imports and components stay as defined in the fragment example. ```tsx import { memo } from "react"; export const CommentView = memo(function CommentView({ comment }: { comment: CommentRef }) { const data = useFragment(CommentFragment, comment); if (data === null) return null; return
  • {data.body}
  • ; }); ``` `memo` only handles React renders caused by unchanged props. It does not block the component's own fragment subscription. An edit to `body` still updates `CommentView`. ## Try a field edit Add this component to the same example: ```tsx // src/CommentControls.tsx import { useState } from "react"; import { store } from "./local-store.ts"; export function CommentControls() { const [error, setError] = useState(null); async function changeComment() { setError(null); try { const oldRow = await store.readOnce(store.query.comment.where.id("c1").one()); if (oldRow === null) return; await store.write((tx) => { tx.edit("comment", oldRow, { ...oldRow, body: "The screenshot is ready." }); }); } catch (cause) { setError(String(cause)); } } return ( <> {error &&

    {error}

    } ); } ``` In `src/main.tsx`, import `CommentControls` from `./CommentControls.tsx`. Render `` beside `` inside the existing provider. The button changes the first comment's text. React DevTools can show the components that render during that change. This direct write is for the example's local store. In a synced app, use a [shared mutator](https://rindle.sh/docs/mutators) for the write. The same fragment subscription pattern applies to optimistic and confirmed changes. ## Know what still changes | Change | Expected local read updates | | --- | --- | | Edit `comment.body` | That comment's fragment read | | Edit `issue.title` | That issue card's fragment read | | Add or remove a comment | The issue card's comment references and affected child reads | | Change a column used to filter or order a list | The affected list and affected row reads | | Change a React prop or context | Normal React rendering rules apply | A fragment cannot isolate fields that it reads together. If a parent uses `useQuery()` to read the complete nested result, child data changes can update that parent. An inline `.sub()` builder also keeps its nested data in the parent's result. Smaller fragments create more local views and subscriptions. Use them where component boundaries and update frequency justify that cost. The Rindle repository's [fragment React tests](https://github.com/tantaman/rindle/blob/main/packages/react/test/fragment-local-reads.test.ts) cover child edits, inserts, stable references, and rendering before server coverage completes. [Folded mutations](https://rindle.sh/docs/folded-mutations) address a different cost: the number of writes sent during repeated edits. They can accompany fragment reads in a drag interaction or text editor. --- [View this page on Rindle](https://rindle.sh/docs/fine-grained-reactivity) --- # Search & typeahead Build live search with escaped filters, available local data, and prompt cleanup of abandoned queries. A search box creates a different query as its term changes. The local engine can show matches from rows it already holds. A named server query supplies matches outside that local data. Offline search only covers available local rows. This recipe extends the [synced-app quickstart](https://rindle.sh/docs/synced-app-quickstart). It uses that app's `q` builder, `issue` table, API server, and `Rindle` provider. Its required issue columns are `id`, `title`, `status`, and `updatedAt`. ## Define and validate the query `%` and `_` are wildcards in `like` and `ilike` patterns. Escape these characters so a search for `100%` treats the percent sign literally. Put the helper beside the query so both tiers use the same pattern. ```ts // shared/search.ts import { defineQuery, ilike } from "@rindle/client"; import { z } from "zod"; import { q } from "./app-def.ts"; export const MAX_SEARCH_LENGTH = 100; export function escapeLike(value: string): string { return value.replace(/[\\%_]/g, (character) => `\\${character}`); } const searchArgs = z.object({ term: z.string().min(1).max(MAX_SEARCH_LENGTH), }); export const searchIssuesQuery = defineQuery( "searchIssues", (raw) => searchArgs.parse(raw), ({ term }) => q.issue .where.title(ilike(`%${escapeLike(term)}%`)) .select("id", "title", "status") .orderBy("updatedAt", "desc") .orderBy("id", "desc") .limit(20), ); ``` In `server/api.ts`, import `searchIssuesQuery` from `../shared/search.ts`. Add it to the existing `registerQueries([...])` array. The API server resolves the named query and validates its arguments. Input validation does not replace [read authorization](https://rindle.sh/docs/authorization). `ilike` performs case-insensitive pattern matching, not relevance ranking or full-text search. A contains pattern starts with `%` and can require a broad scan during the initial read. The 20-row limit bounds the result, not necessarily the work needed to find it. See [query shapes](https://rindle.sh/docs/supported-queries-ts) for matching and escape semantics. ## Render local results and server readiness Create the search component: ```tsx // src/SearchBox.tsx import { useState } from "react"; import { useQuery, useQueryStatus } from "@rindle/react"; import { MAX_SEARCH_LENGTH, searchIssuesQuery } from "../shared/search.ts"; export function SearchBox() { const [input, setInput] = useState(""); const term = input.trim(); return (
    {term.length > 0 && }
    ); } export function SearchResults({ term }: { term: string }) { const query = searchIssuesQuery({ term }); const rows = useQuery(query, { releaseDelayMs: 0 }); const status = useQueryStatus(query, { releaseDelayMs: 0 }); return (
      {rows.map((row) =>
    • {row.title}
    • )}
    {status !== "complete" &&

    Searching…

    } {status === "complete" && rows.length === 0 &&

    No matches.

    }
    ); } ``` Render `` under the app's existing provider. An empty or whitespace-only term does not mount `SearchResults`. This keeps the hooks unconditional and avoids requesting every row with `ilike("%%")`. Visible matches can include optimistic writes and retained local rows. The loading message remains until the server confirms this term's coverage. Only a complete empty result displays "No matches." `releaseDelayMs: 0` releases abandoned terms without the default two-second retention period. Both hooks use it because each hook retains the query. Another reader's later retention deadline can still keep a shared query alive. See [query retention](https://rindle.sh/docs/client) for the full lifecycle. ## Search only the local rows An unnamed builder query does not request additional server coverage. This component searches the issue rows already available in the same provider's store: ```tsx // src/LocalIssueSearch.tsx import { ilike } from "@rindle/client"; import { useQuery } from "@rindle/react"; import { q } from "../shared/app-def.ts"; import { escapeLike } from "../shared/search.ts"; export function LocalIssueSearch({ term }: { term: string }) { const rows = useQuery( q.issue .where.title(ilike(`%${escapeLike(term)}%`)) .select("id", "title") .orderBy("title", "asc") .orderBy("id", "asc") .limit(20), { releaseDelayMs: 0 }, ); return
      {rows.map((row) =>
    • {row.title}
    • )}
    ; } ``` Use this component for a filter over data that another query already retains. An empty local result does not prove that no matching issue exists on the server. [Preloads](https://rindle.sh/docs/preloads) can retain a known query, but they do not load an entire table automatically. ## Reduce requests with a timed debounce Every distinct named-query term can open a server subscription. To reduce short-lived terms, wait for a pause in typing before mounting the next result query. The input itself still updates on each keystroke. This optional component uses the same `SearchResults`: ```tsx // src/DebouncedSearchBox.tsx import { useEffect, useState } from "react"; import { MAX_SEARCH_LENGTH } from "../shared/search.ts"; import { SearchResults } from "./SearchBox.tsx"; export function DebouncedSearchBox() { const [input, setInput] = useState(""); const [term, setTerm] = useState(""); useEffect(() => { const timer = setTimeout(() => setTerm(input.trim()), 150); return () => clearTimeout(timer); }, [input]); return (
    {input.trim() !== term &&

    Waiting for typing to pause…

    } {input.trim().length > 0 && term.length > 0 && ( <>

    Results for: {term}

    )}
    ); } ``` A debounce reduces requests during a burst. It is not a server rate limit. React's `useDeferredValue` schedules rendering and does not guarantee fewer network requests. --- [View this page on Rindle](https://rindle.sh/docs/typeahead) --- # Streaming LLM responses Combine live response text with database checkpoints so reloads and other devices can recover the stream. Rindle can deliver generated text as live events while storing periodic checkpoints. The live events reduce display latency. The checkpoints let another client recover the stored text through an ordinary query. This guide provides integration modules for an existing synced app. It assumes SQL migrations, generated TypeScript tables, a browser client, and an HTTP server that can return a Fetch `Response`. See the [synced-app quickstart](https://rindle.sh/docs/synced-app-quickstart) for those foundations. The example uses public response records and a deterministic demo producer. It does not call a model provider. A private chat app must add authenticated ownership checks, as described in the final section. ## Understand the two sources of text | Source | Content | Lifecycle | | --- | --- | --- | | Database query | The stored body and checkpoint chunks | Available through normal synchronization | | Live stream | Text produced since the subscriber's starting offset | Hosted by one API process, with optional relay | Offsets count UTF-16 code units, the unit used by JavaScript string lengths. The default checkpoint triggers are 512 code units or 750 milliseconds. Checkpoints run serially. A slow database can produce fewer, larger checkpoints. These defaults are not latency or durability guarantees. On successful completion, one transaction writes the whole response to `body` and removes its chunk rows. The client combines the stored prefix with live text without counting the checkpoint twice. Live text that has not reached a checkpoint can be lost after a process failure. ## Add the tables Add these tables in a new migration: ```sql CREATE TABLE response ( id TEXT NOT NULL PRIMARY KEY, prompt TEXT NOT NULL, body TEXT NOT NULL DEFAULT '', seq INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'pending', cancelRequested INTEGER NOT NULL DEFAULT 0, host TEXT NOT NULL DEFAULT '' ); CREATE TABLE response_chunk ( id TEXT NOT NULL PRIMARY KEY, streamId TEXT NOT NULL, seq INTEGER NOT NULL, text TEXT NOT NULL ); CREATE INDEX response_chunk_order ON response_chunk (streamId, seq); ``` Apply the migration and regenerate `shared/schema.gen.ts` through your existing `rindle dev --migrate --gen` command. The examples use its `schema`, `response`, and `response_chunk` exports. They do not modify generated files by hand. `seq` must start at zero and cannot be `NULL`. The stream uses it to avoid applying a checkpoint twice. The mapped `host` column also lets competing processes resolve which producer opened the response. The stream owns `body`, `seq`, and `status` after opening. ## Define the shared writes and read A start mutation creates the response row before generation starts. A cancel mutation changes durable state that the producer reads during a checkpoint. ```ts // shared/responses.ts import { defineMutators, defineQuery, newQueryBuilder } from "@rindle/client"; import { z } from "zod"; import { schema, response_chunk } from "./schema.gen.ts"; export const startResponseArgs = z.object({ id: z.string().uuid(), prompt: z.string().min(1).max(4000), }); export type StartResponseArgs = z.infer; const responseIdArgs = z.object({ id: z.string().uuid() }); const { shared } = defineMutators(schema); export const responseMutators = { startResponse: shared(startResponseArgs, function* (tx, args) { yield tx.insert("response", { id: args.id, prompt: args.prompt, body: "", seq: 0, status: "pending", cancelRequested: 0, host: "", }); }), cancelResponse: shared(responseIdArgs, function* (tx, args) { yield tx.update("response", { id: args.id, cancelRequested: 1 }); }), }; const q = newQueryBuilder(schema); export const responseQuery = defineQuery( "response", (raw) => responseIdArgs.parse(raw), ({ id }) => q.response.where.id(id) .select("id", "prompt", "body", "seq", "status", "cancelRequested") .sub("chunks", response_chunk, { parent: ["id"], child: ["streamId"] }, (chunks) => chunks.select("seq", "text").orderBy("seq", "asc"), ) .one(), ); ``` Add `responseMutators` to the browser client's mutator registry. For example, merge it with the quickstart's existing registry as `{ ...mutators, ...responseMutators }`. Keep the same combined registry type in your client module. ## Start the producer after the row commits This producer defines the input shape required by `pump`: an `AsyncIterable`. Replace its implementation with your model SDK adapter when integrating a real model. ```ts // server/demo-text.ts import { setTimeout as delay } from "node:timers/promises"; export async function* demoText(prompt: string): AsyncIterable { const words = `This is a demonstration response to: ${prompt}`.split(" "); for (const word of words) { await delay(50); yield `${word} `; } } ``` Create a long-lived API instance for the response demo: ```ts // server/response-api.ts import { createRindleApiServer, defineApiMutators, registerQueries, runSharedMutation, scoped, sharedApiMutators, } from "@rindle/api-server"; import type { ApiMutators, RindleApiServer } from "@rindle/api-server"; import { schema } from "../shared/schema.gen.ts"; import { responseMutators, responseQuery, startResponseArgs } from "../shared/responses.ts"; import { demoText } from "./demo-text.ts"; const sharedContext = () => ({ user: "public-demo" }); const apiMutators = defineApiMutators>({ ...sharedApiMutators(responseMutators, sharedContext), startResponse: scoped(async (scope, raw) => { const args = startResponseArgs.parse(raw); const start = await scope.transact(async (tx) => { if (await tx.row("response", { id: args.id })) return false; await runSharedMutation(responseMutators.startResponse, args, sharedContext(), tx); return true; }); if (start) { void produceResponse(args.id, args.prompt).catch((error) => { console.error("Response generation failed", args.id, error); }); } }), }); export const responseApi: RindleApiServer = createRindleApiServer({ rindle: {}, // RINDLE_URL and RINDLE_DATABASE_TOKEN stay on the server. schema, queries: registerQueries([responseQuery]), mutators: apiMutators, authorizeQuery: () => true, authorizeMutation: () => true, streams: { checkpoint: { tables: { message: "response", chunks: "response_chunk", columns: { cancel: "cancelRequested", host: "host" }, }, }, authorize: () => true, // Every response in this demonstration is public. }, }); async function produceResponse(streamId: string, prompt: string): Promise { const stream = await responseApi.openStream({ user: undefined, streamId }); try { await stream.pump(demoText(prompt)); await stream.close(); } catch (error) { await stream.fail(error); } } ``` The `scoped` mutator completes its database transaction before it starts generation. The transaction guard skips an already-created response. `openStream` also refuses a missing row, an advanced response, or an active competing producer. A regeneration needs a new response ID. `streams.authorize` guards subscribers. It does not authorize `openStream`. Generation starts through trusted server code after mutation authorization. The example's public authorizers are deliberate and are unsuitable for private conversations. The process must remain alive while `produceResponse` runs. Starting work after a commit is not a durable job queue. A crash between committing the row and starting generation can leave it `pending`. For reliable background generation, persist a job and run it through your application's worker lifecycle. For a real model adapter, release or abort the upstream request in the iterator's `finally` block. `pump` closes the iterator when it observes cancellation, but the adapter owns the provider-specific cleanup. For an ordered tool-result write, call `await stream.flush()` before storing the separate result row. ## Mount the HTTP operations This Fetch-style handler defines both the JSON operations and the SSE subscription: ```ts // server/response-handler.ts import { RindleApiError } from "@rindle/api-server"; import { responseApi } from "./response-api.ts"; export async function handleResponseRequest(request: Request): Promise { const path = new URL(request.url).pathname; const context = { user: undefined, request }; try { if (request.method === "GET" && path === responseApi.routes.stream) { return await responseApi.streamResponse(request, context); } if (request.method !== "POST") return new Response("Not found", { status: 404 }); const body: unknown = await request.json(); if (path === responseApi.routes.query) { return Response.json(await responseApi.handleQueryJson(body, context)); } if (path === responseApi.routes.read) { return Response.json(await responseApi.handleReadJson(body, context)); } if (path === responseApi.routes.mutate) { return Response.json(await responseApi.handleMutateJson(body, context)); } return new Response("Not found", { status: 404 }); } catch (error) { const status = error instanceof RindleApiError ? error.status : error instanceof SyntaxError ? 400 : 500; console.error(error); return Response.json({ error: "Response request failed" }, { status }); } } ``` Mount this function through your HTTP framework's Request/Response adapter. The default paths are `/api/rindle/query`, `/api/rindle/read`, `/api/rindle/mutate`, and `/api/rindle/stream`. A proxy must forward the stream response without buffering it to completion. All handlers must use the same long-lived API instance to access its in-process live streams. Do not construct and close an API instance for each SSE request. To retain existing app operations, combine their query and mutator registries in this same instance. Their authorization policies must also remain explicit. ## Render the stored prefix and live text This component uses the quickstart's exported `app` after adding `responseMutators` to its browser registry: ```tsx // src/ResponseText.tsx import { assembleDurableText } from "@rindle/client"; import { useQuery, useQueryStatus, useStreamedText } from "@rindle/react"; import { responseQuery } from "../shared/responses.ts"; import { app } from "./rindle-client.ts"; export function ResponseText({ id }: { id: string }) { const query = responseQuery({ id }); const data = useQuery(query); const status = useQueryStatus(query); const streaming = data?.status === "streaming"; const text = useStreamedText({ streamId: id, durable: assembleDurableText(data, data?.chunks ?? []), live: streaming, }); if (data === null) { return

    {status === "complete" ? "Response not found." : "Loading response…"}

    ; } return (

    {data.prompt}

    {text}

    {data.cancelRequested && streaming ? "Stopping…" : data.status}

    {streaming && !data.cancelRequested && ( )}
    ); } ``` Render this component under the same `Rindle` provider as the browser client. Pass a response ID from your route or application state. Start a response from a submit handler with this helper: ```ts // src/start-response.ts import { startResponseArgs } from "../shared/responses.ts"; import { app } from "./rindle-client.ts"; export function startResponse(prompt: string): string { const args = startResponseArgs.parse({ id: crypto.randomUUID(), prompt: prompt.trim() }); app.mutate.startResponse(args); return args.id; } ``` The returned ID identifies a local prediction, not an accepted or completed response. Retain it in route or application state so a reload can query the same response. Use the client's [rejection callback](https://rindle.sh/docs/rejected-writes) to report a refused start or cancel mutation. The hook subscribes only after the row becomes `streaming`. Subscribing while it is still `pending` can reach the server before a producer exists and receive `absent`. After `absent`, `stale`, or `end`, the hook detaches and continues to use the stored query result. A checkpoint does not reopen the subscription. Without `EventSource`, the hook uses only the durable text. The default transport cannot add arbitrary authentication headers. For private streams, use a same-origin authenticated session or supply a custom `StreamTransport`. ## Cancellation, failures, and shutdown A cancel mutation sets `cancelRequested` in the database. The producer observes that flag on a checkpoint round trip. `pump` then stops as the upstream iterator yields. A stalled iterator or slow database can delay cancellation beyond the configured checkpoint interval. `close()` stores the complete produced body and removes the chunks. `fail(error)` tries to store that body with an `error` status. Both operations can reject if the final database write fails. The live tail is not durable merely because the client displayed it. On shutdown, stop accepting new generation work, then call `await responseApi.drainStreams()` before `responseApi.close()`. Draining attempts to store active streams and mark them `interrupted`. A hard process failure cannot perform that step. Use an application recovery job for abandoned `pending` or `streaming` rows. See [background writes](https://rindle.sh/docs/background-writes) for the write pattern. Mapped-table mode retains the whole generated response in process memory until sealing and later eviction. It does not support `retainChars` as a memory limit. Bound generation length and concurrency in the application. With several API instances, a subscriber can reach a process that does not host its stream. Without a relay, that process returns `absent`, and the query continues to receive checkpoints. Route to the producing process or configure `streams.relay` for live delivery across instances. A relay does not replace the database or its authorization rules. ## Make a private conversation private For a private app, replace all three public authorizers in the example. Derive the user from a verified server session. Scope the response query, start and cancel mutations, and stream subscription to the same ownership policy. The default SSE transport needs authentication that the browser can send on that request. A subscriber can reach a process without the live stream's `meta` value. Authorize from the response ID and durable application data in that case. An unpredictable ID alone is not an ownership check. See [authorization](https://rindle.sh/docs/authorization) for context-scoped queries and guarded writes. --- [View this page on Rindle](https://rindle.sh/docs/llm-streams) --- # Agents on live data Turn live query changes into named events and digests for an agent that uses your application data. An application agent can observe live query changes and use them as model input. `@rindle/narrator` converts those changes into text through templates that you define. Rindle does not select a model, manage its context, or decide which actions it can perform. For instructions for a coding agent working on your app, read [For coding agents](https://rindle.sh/docs/for-agents) instead. ## Start with a known query This example continues the [fragment example](https://rindle.sh/docs/fragments). It uses that guide's `shared/fragments.ts`, which defines `issueCardsQuery`, `schema`, and the issue and comment tables. The Node demonstration creates its own store. It does not import the browser startup module. Install the narrator in that project: ```sh pnpm add @rindle/narrator ``` Define templates for the query's root and its `comments` relationship: ```ts // shared/narrators.ts import type { NarratorRegistry } from "@rindle/narrator"; export const narrators = { issueCards: { salience: "info", root: { add: ({ row }) => `Issue ${JSON.stringify(row.title)} entered the visible list.`, remove: ({ row }) => `Issue ${JSON.stringify(row.id)} left the visible list.`, edit: ({ row, old }) => row.title === old?.title ? null : `Issue ${JSON.stringify(row.id)} is now titled ${JSON.stringify(row.title)}.`, }, related: { comments: { salience: "ambient", add: ({ row }) => `Comment ${JSON.stringify(row.body)} entered the issue.`, remove: ({ row }) => `Comment ${JSON.stringify(row.id)} left the issue.`, edit: ({ row, old }) => row.body === old?.body ? null : `Comment ${JSON.stringify(row.id)} changed to ${JSON.stringify(row.body)}.`, }, }, }, } satisfies NarratorRegistry; ``` The registry key `issueCards` matches the name passed to `defineQuery`. The relationship key `comments` matches the alias passed to `.sub()`. A template returns a string, or `null` to suppress that event. Templates describe changes to query results. A row leaving a filtered or limited query does not necessarily mean that someone deleted it. That distinction matters when an agent decides what happened. ## Capture a snapshot, then changes A view's `onChanges` callback receives its changes, delivery phase, and wire schema. `narrate` resolves positional rows to named fields and applies the templates. `digest` formats the resulting events, ordered by salience: `alert`, `info`, then `ambient`. Create this Node program: ```ts // scripts/narration-demo.ts import { createWasmStore } from "@rindle/wasm"; import { createNarrator } from "@rindle/narrator"; import type { SemanticEvent } from "@rindle/narrator"; import { issueCardsQuery, schema } from "../shared/fragments.ts"; import { narrators } from "../shared/narrators.ts"; const store = await createWasmStore(schema); const initialComment = { id: "c1", issueId: "i1", body: "Add a screenshot." }; await store.write((tx) => { tx.add("issue", { id: "i1", title: "Ship the example" }); tx.add("comment", initialComment); }); const narrator = createNarrator(narrators); let pending: SemanticEvent[] = []; const view = store.materialize(issueCardsQuery(), { onChanges: (changes, phase, wireSchema) => { if (phase !== "batch") return; pending.push(...narrator.narrate("issueCards", wireSchema, changes, phase)); }, }); try { // This local store is already complete. A synced client must establish readiness first. const context: Array<{ role: "user"; content: string }> = [{ role: "user", content: `Current query result:\n${JSON.stringify(view.data)}`, }]; await store.write((tx) => { tx.edit("comment", initialComment, { ...initialComment, body: "The screenshot is ready.", }); }); const block = narrator.digest(pending); pending = []; if (block.length > 0) context.push({ role: "user", content: `Data changes:\n${block}` }); console.log(JSON.stringify(context, null, 2)); } finally { view.destroy(); } ``` Run it with Node 22.18 or later: ```sh node scripts/narration-demo.ts ``` The output contains an initial query result and a text description of the comment edit. The program does not call a model or perform an agent action. The `context` array illustrates input that your application can pass to its model integration. The callback only collects events. The application drains them after the write finishes. Use the same separation for an agent that writes: observe, queue work, then act outside the callback. ## Understand the events | Template field | Meaning | | --- | --- | | `row` | The changed row, with named fields | | `old` | The previous row for an edit | | `parent` | The immediate containing row for a nested change | | `sub(alias)` | A correlated sub-row available on an add | | `aggregate` | The alias and projected value for a count change | | `context` | Application context passed to `narrate` | `related` accepts an alias such as `comments`, or a full dotted alias path for a deeper relationship. A dotted key takes precedence over a matching leaf alias. `counts` maps a `countAs` alias to its template. Use `resolveChange` from `@rindle/client` when you need named changes without text templates. A view combines changes that cancel within one delivery. An accurate optimistic write can produce an event when the prediction first changes the view. Its later confirmation produces no additional event if the visible result stays unchanged. Narration is a result-change feed, not an audit log or mutation acknowledgement channel. The narrator does not identify the actor automatically. If attribution matters, store a trusted actor field with the application write. A deleted row's last-edit actor does not identify who deleted it. Use an audit record or an explicit soft-delete field for that distinction. ## Buffer narration in React The optional React package manages a dedicated narration view and event buffer: ```sh pnpm add @rindle/narrator-react ``` Add this component under the fragment example's existing `Rindle` provider: ```tsx // src/NarrationPreview.tsx import { useState } from "react"; import { createNarrator } from "@rindle/narrator"; import { useNarration } from "@rindle/narrator-react"; import { issueCardsQuery } from "../shared/fragments.ts"; import { narrators } from "../shared/narrators.ts"; const narrator = createNarrator(narrators); export function NarrationPreview() { const [text, setText] = useState(""); const buffer = useNarration(issueCardsQuery(), narrators, { phases: ["batch"], max: 200, }); return (
    {text}
    ); } ``` The handle stays stable and does not cause a render for each event. `take()` returns and clears the events. `clear()` discards them. The default phase is `batch`, so initial snapshot rows do not appear as new changes. Add `snapshot` to `phases` to include them. The default buffer limit is 200 events. Overflow discards the oldest events. A changed query or registry, or an unmount, also clears the buffer. Keep the registry at module scope to avoid resubscribing on each render. An unnamed query needs `as` in the options to identify its registry entry. The hook creates its own materialized view and destroys it on cleanup. It does not reuse another component's `useQuery` view. ## Connect an agent deliberately Before sending a snapshot from a synced client, establish the readiness your task needs. A partial local result is not a complete server result. For a long-running agent, retain its query and manage reconnects, errors, and context size. Treat row text as application data in the model input. Use explicit action policies and validated mutators for writes. A model response does not become an authorized mutation by appearing in this feed. Deduplicate scheduled actions and prevent the agent from repeatedly responding to its own changes. The narrator formats JavaScript view changes. It does not require React, a model SDK, or a particular storage backend. The chosen store still determines runtime support, synchronization, and deployment requirements. See [client setup](https://rindle.sh/docs/client), [mutators](https://rindle.sh/docs/mutators), and [testing](https://rindle.sh/docs/testing) for those parts. --- [View this page on Rindle](https://rindle.sh/docs/agents) --- # Background & system writes Write SQL from jobs, webhooks, and services, and understand how those writes reach subscribed clients. Jobs, webhooks, and queue consumers can write to Rindle without a browser client. These **system writes** use SQL and have no optimistic prediction to confirm. This guide assumes a Rindle database and a trusted server process. Start with [Rindle SQL](https://rindle.sh/docs/sql-client) for the connection and transaction API. > A client mutation carries `{ clientID, mid }` and advances that client's `lmid` — the watermark > that releases its optimistic prediction. A system write has **no prediction to release**, so it > carries no client identity and must never advance an lmid. The system-write API is ordinary SQL. ## The default: plain SQL through the same ingress `@rindle/sql-client` talks to the same unified URL as everything else. Ordinary `execute` / `batch` / `withTransaction` calls never enter the mutation protocol (only the explicit mutation facade accepts an envelope, so generic SQL can't reach it by accident): ```ts // server/sweeper.ts import { createSqlClient } from "@rindle/sql-client"; const sql = createSqlClient({ url: process.env.RINDLE_URL!, // both exported by `rindle dev`; authToken: process.env.RINDLE_DATABASE_TOKEN!, // Rindle Cloud's Connect panel supplies the pair }); // The sweeper the LLM-streams recipe asks for: a host that died mid-generation // leaves rows saying `streaming` forever — reap them past any plausible runtime. const STALE_MS = 10 * 60 * 1000; export async function sweepInterruptedStreams(): Promise { await sql.execute({ sql: "update message set status = 'interrupted' where status = 'streaming' and createdAt < ?", args: [Date.now() - STALE_MS], }); } ``` The write follows the database's normal change path. In a fleet, it commits on the write master and replicates to followers. A standalone daemon serves the write and its subscriptions directly. Connected clients receive changes to their subscribed queries as those changes reach the serving replica. A successful commit does not mean that every subscriber received it. Clients can disconnect or lag behind. Replication errors can reject a write, but commit acknowledgment and client delivery are separate events. Scheduling is yours — a `setInterval` in your server process is fine for a sweeper: ```ts const timer = setInterval(() => { void sweepInterruptedStreams().catch((err) => console.error("sweep failed:", err)); }, 60_000); process.on("SIGTERM", () => { clearInterval(timer); void sql.close(); }); ``` (A serverless cron — a Cloudflare Cron Trigger, a scheduled Action — is the same code with the platform's scheduler instead of the interval. You created this client, so you close it.) Two knobs worth knowing: - **Read-your-writes.** SQL reads use session consistency by default and go to the write authority. Use `withTransaction` for a read and its dependent write. See [SQL consistency](https://rindle.sh/docs/sql-client#consistency-and-read-your-writes) for session cursors across requests. - **Bulk writes.** Use bounded batches and account for the number of parameters per row. Split large backfills into several transactions. The [SQL guide](https://rindle.sh/docs/sql-client) covers transaction limits and retries. ## Retries: make the write idempotent, not the scheduler careful Everything in this recipe re-runs — crons overlap, webhook providers redeliver, queues are at-least-once. Don't fight that upstream. Make the write safe to repeat. Three mechanisms, in order of preference: **1. Deterministic statements.** The sweeper above is already idempotent: re-running the `UPDATE` matches nothing the first run didn't. Prefer shapes that are no-ops on replay — `ON CONFLICT DO NOTHING`, guarded `UPDATE … WHERE status = 'x'`, a compare-and-swap on a version column. **2. A unique key the retry collides with.** For webhooks, the provider's event id is that key. Record it in the same transaction as the effect: This example assumes `webhook_event(id TEXT PRIMARY KEY, receivedAt REAL)` and `account(id TEXT PRIMARY KEY, plan TEXT, planUpdatedAt REAL)`. Your HTTP adapter must verify the provider's signature before passing the event to this function: ```ts import type { SqlClient } from "@rindle/sql-client"; interface BillingEvent { id: string; accountId: string; plan: string; } export async function applyBillingEvent(sql: SqlClient, event: BillingEvent) { return sql.withTransaction(async (tx) => { const claimed = await tx.execute({ sql: "insert into webhook_event (id, receivedAt) values (?, ?) on conflict (id) do nothing", args: [event.id, Date.now()], }); if (claimed.rowsAffected === 0) return false; await tx.execute({ sql: "update account set plan = ?, planUpdatedAt = ? where id = ?", args: [event.plan, Date.now(), event.accountId], }); return true; }); } ``` A repeated event ID returns `false` without applying the effect again. The receipt and update commit together. Other database errors propagate to the HTTP adapter; they are not mistaken for duplicate delivery. Decide separately how your application handles valid provider events that arrive out of order. **3. A durable producer watermark on the transaction.** The daemon client's `executeSqlTxn` accepts `producer: { id, seq }` and dedupes it durably server-side. It is the right tool for one-shot setup like a boot seed, where the guard must survive restarts without you modeling a table for it: For a previously migrated `category(id TEXT PRIMARY KEY, name TEXT)` table: ```ts import type { RindleDaemonClient } from "@rindle/daemon-client"; export async function seedCatalog(daemon: RindleDaemonClient) { return daemon.executeSqlTxn({ producer: { id: "seed-catalog-v1", seq: 1 }, statements: [ { sql: "insert into category (id, name) values (?, ?)", params: ["general", "General"] }, ], }); } ``` Pass a configured, trusted control client with a write-authority route. A follower-only connection cannot execute this system write. Repeating the same producer sequence returns `applied: false` without executing the statements. The rules, because they are a real constraint: - **`id` names the writer, `seq` numbers its writes.** `seq <= last` is absorbed (`applied: false`, no statements run), `seq === last + 1` applies, and anything beyond is a `409` gap. So a long-lived producer must number `1, 2, 3, …` and **send them in order** — the reply for `seq: n` before submitting `n + 1`. - **Concurrency means several producer ids, not unordered submission under one.** A worker pool fanning out under a single id will draw spurious gap rejections; give each worker its own id. - **Keep ids stable and few.** The daemon stores one row per producer and overwrites it in place, so the dedup state is bounded by how many producers exist — not by how much they write, and never swept. A fresh id per process run puts that growth back. Nothing server-side can stop that, so watch it instead: `rindle_write_producers` on a standalone daemon, or `rindle_replicator_write_producers` on a write-master, counts your distinct producer ids. It should settle; a line that keeps climbing means something is minting ids per run. - **`producer` and `clientID`/`mid` are mutually exclusive.** Sending both is a `400`. A mutation from an optimistic client already has `mid` as its durable retry identity. This gives you "don't apply twice", not "give me the original answer back": an absorbed replay reports `applied: false` and no stored result. If the retry needs the *exact original reply* — the returned rows or assigned IDs — the lower-level `/v1/sql/execute` HTTP protocol can replay an outcome for a retained `idempotency_key`. Its cache is bounded by TTL and quota, and expires old keys with `410`. The TypeScript SQL client's automatic key survives its internal retries but is not exposed for reuse across separate calls. For application-level recovery across invocations, a receipt table like the webhook example is usually the clearer contract. ## When you actually want a mutator: mint an envelope Sometimes the background job needs to run *your mutator* — same validation, same policy guards, same body the clients predict — rather than restate its SQL. The api-server exposes the same in-process entry the HTTP route uses: This function assumes `closeStaleIssues` is registered on the API server and its authorizers permit the application's `system:scheduler` principal: ```ts import type { RindleApiServer } from "@rindle/api-server"; export async function runIssueCleanup( api: RindleApiServer, jobRunId: string, before: number, ) { const out = await api.pushMutation({ user: "system:scheduler", envelope: { clientID: `cron:${jobRunId}`, mid: 1, name: "closeStaleIssues", args: { before }, }, }); if (!out.accepted) console.error("closeStaleIssues rejected:", out); return out; } ``` Keep `jobRunId` stable across retries of the same logical job. A new ID describes a new mutation sequence. This is the full client path — arg parse, `authorizeMutation`, the shared body, and yes, an lmid advance for that synthetic clientID. Envelope semantics are the point here. Which brings the one real trap: **per clientID, `mid` must be exactly `lmid + 1`.** On the standard Rindle SQL mutation backend, a lower `mid` is absorbed as a replay — `accepted` with **no effects and no error** — and a gap is a hard 409. So a worker that reuses one `clientID` with an in-memory counter silently does nothing after a restart, and two instances sharing a `clientID` conflict. The safe patterns are either a **fresh `clientID` per job run** with `mid: 1` (shown above), or a persisted counter. The preview Postgres adapter has different replay limits; see [Postgres mutations](https://rindle.sh/docs/postgres-source#mutators-against-a-postgres-authority). If you find yourself engineering around this, stop — the envelope machinery exists for optimistic clients. Your job probably wanted a plain system write, with the shared logic extracted into a function both call. For a mutation that must trigger a background *effect* (start a generation, send an email), that belongs inside the mutator itself via `scoped()` post-commit. See [the API server](https://rindle.sh/docs/api-server#work-outside-the-mutation-transaction) and the [LLM-streams recipe](https://rindle.sh/docs/llm-streams) for the worked pattern, including the transactional replay guard that keeps a retried envelope from firing the effect twice. ## What the user sees A committed system write updates subscribed views through the ordinary sync protocol. It can also cause clients to rebase pending predictions over the new server state. It does not confirm any client's pending mutation. The sweeper's status change reaches each subscribed tab through this same process. ## See also - [The SQL surface](https://rindle.sh/docs/sql-client) — `createSqlClient`, transactions, consistency modes, and session cursors. - [The API server](https://rindle.sh/docs/api-server) — `scoped()` post-commit effects, and the bulk out-of-band write guidance. - [Streaming LLM responses](https://rindle.sh/docs/llm-streams) — the plane whose sweeper this recipe implements. - [Isomorphic mutators](https://rindle.sh/docs/mutators) — what an envelope buys you when you do want one. --- [View this page on Rindle](https://rindle.sh/docs/background-writes) --- # Postgres as the source of truth Preview: keep PostgreSQL authoritative while Rindle captures its changes and serves live queries. Review setup, limits, and recovery. > **Preview:** the direct Postgres source is implemented and tested, but it is not > a supported production topology. Managed-provider validation and production > sizing remain open release requirements. Managed databases can also restrict DDL > capture and require TLS configuration. Use this integration when PostgreSQL must remain the authoritative database. Applications keep their writes in Postgres. Rindle copies the selected tables into read-only followers, maintains queries over those copies, and streams changes to clients. This setup adds a gateway, an archive, a snapshot producer, and Rindle followers. It requires PostgreSQL logical replication and operational control over its publication and replication slot. It is more involved than a [standard Rindle deployment](https://rindle.sh/docs/deploy). The **gateway** reads `pgoutput`, PostgreSQL's logical replication stream. It archives each transaction and forwards the changes to followers. A **snapshot producer** creates snapshots for new or recovering followers. ``` PostgreSQL ──pgoutput──▶ rindle-pg-gateway ──frames──▶ archive (S3 / filesystem) ▲ │ fan-out │ │ slot ack = what the ▼ ▼ │ archive holds rindled followers backup producer (snapshots) ``` Named queries and live subscriptions read from the followers. Writes go to Postgres through your existing services or Rindle's mutators. This integration does not make `@rindle/sql-client` a Postgres SQL endpoint. Before deployment, review [requirements](#what-is-admitted), [type mappings](#types), [schema changes](#schema-changes), and [recovery consequences](#recovery-consequences). ## Mutators against a Postgres authority The preview includes a `postgresBackend` adapter for [isomorphic mutators](https://rindle.sh/docs/mutators). It runs the server body against Postgres while the browser predicts against its local engine. It has different query and replay limits from the standard SQL mutation backend. The following factory uses the shared example from the [browser client guide](https://rindle.sh/docs/client#define-the-shared-query-and-writes). Generate that schema from the follower's mirrored Postgres tables. Pass a configured follower control client and a caller-owned `pg.Pool`: ```ts import { createRindleApiServer, pgPoolPlugger, postgresBackend, registerQueries, sharedApiMutators, } from "@rindle/api-server"; import type { PgPoolLike } from "@rindle/api-server"; import type { RindleDaemonClient } from "@rindle/daemon-client"; import { issuesPageQuery, mutators, schema } from "../shared/client-example.ts"; type User = string | undefined; export function createPostgresApi(daemon: RindleDaemonClient, pool: PgPoolLike) { return createRindleApiServer({ daemon, backend: postgresBackend(pgPoolPlugger(pool)), schema, queries: registerQueries([issuesPageQuery]), mutators: sharedApiMutators(mutators, ({ user }) => { if (!user) throw new Error("authentication required"); return { user }; }), authorizeQuery: ({ user }) => Boolean(user), authorizeMutation: ({ user }) => Boolean(user), }); } ``` This factory does not create the gateway or follower. The caller also owns the Postgres pool's shutdown, normally `await pool.end()`. Use the [HTTP adapter](https://rindle.sh/docs/api-server#bring-your-own-http) to supply verified users. When an explicit control client returns no public WebSocket endpoint, configure `daemon: { wsUrl }` on the browser client with the follower's public endpoint. The server mutator gets one Postgres transaction. Logical writes and point reads through `tx.row` use that transaction, so reads see earlier writes. Raw SQL uses Postgres placeholders (`$1`, `$2`, and so on). The optional `rewriteSql: questionToDollarParams` adapter translates `?` placeholders in raw statements. Logical operations render the correct dialect automatically. Two current limits affect application correctness: - **Full query reads are unavailable.** `tx.query` throws on this backend. Use primary-key reads through `tx.row` or deliberate server-only `tx.sql.query`. A shared mutator that yields `tx.query` cannot run here yet. - **Mutation replay is not deduplicated before the body.** The adapter runs the body and monotonically upserts the mutation watermark. It does not reject sequence gaps or skip an already-seen mutation ID. An HTTP retry can therefore repeat non-idempotent work. Use idempotent operations or an application-owned transactional replay guard. Do not assume the standard SQL backend's replay protection applies to this preview adapter. One piece has to exist in Postgres for the loop to close, and `rindle pg prepare` creates it for you with `--client-mutations`: ```sh rindle pg prepare --pg-host db --pg-user rindle --pg-database app \ --tables public.issue,public.comment --source app --client-mutations ``` That creates `public._rindle_client_mutations (client_id text primary key, last_mutation_id bigint not null)` **and adds it to the publication**. It is how a client learns its writes landed: the API server upserts the client's `last_mutation_id` in the same transaction as the mutation's data, so the confirmation rides the same commit through `pgoutput` to the followers and reaches the browser in the same coherent release as the rows it confirms. Create it without publishing it and every client's pending queue waits forever. A rejected mutation still advances `last_mutation_id` — that is what drains the client's queue and snaps the optimistic prediction back; there is no rejection signal on the replication path. Plain Postgres writers — a DBA, a cron job, another service — keep flowing as ordinary data with no `last_mutation_id`, confirming nothing. The two coexist. The server mutator reads the current Postgres transaction. The client predicts against its local copy, which can lag behind Postgres. Concurrent writes or missing local rows can therefore change the predicted result. Reconciliation applies the authoritative changes and replays pending mutations. Predictions can also differ from authority in other Rindle deployments. The Postgres topology adds replication lag between the source database and Rindle's followers, which can increase that difference. ## What is admitted The gateway checks the live catalog before it mints a generation (`rindle pg preflight`): - **PostgreSQL 15 or newer**, `wal_level = logical`, not in recovery, headroom in `max_replication_slots` / `max_wal_senders`. The replication connection must reach the primary directly — not through PgBouncer or another pooler: Neon's `-pooler` host, Supabase's Supavisor host or port 6543, and any host that names a pooler are refused by name. - **The managed provider**, when the catalog identifies one (Neon, Supabase, RDS, Aurora, Cloud SQL, or Azure), or when you declare it with `--provider`. PlanetScale can be selected explicitly because it has no catalog fingerprint. Detection enables provider-specific gates and warnings. Hostname-only hints do not certify a provider or its operational policy. Review the [current checks](#managed-providers) and verify privileges, slot retention, billing, and failover behavior with your provider before deployment. - **Row-level security.** A published table with RLS enabled is refused unless the connecting role is a superuser, has `BYPASSRLS`, or owns the table (without `FORCE ROW LEVEL SECURITY`): the initial copy and every backfill run as that role and would see only policy-visible rows, while the stream carries every row. On Supabase, where RLS is on by default, give the capture role `BYPASSRLS`. - **A publication** that publishes `insert`, `update`, `delete`, and `truncate` for every captured table (`publish_via_partition_root` for partitioned tables). Column lists and row filters are honored; `rindle pg prepare` creates the publication for you. - **A row key per table**: the primary key, or the first unique, immediate, non-partial index whose columns are all `NOT NULL` — which must also be the table's replica identity. `REPLICA IDENTITY DEFAULT` is enough; `FULL` is not required for TOAST columns. - **A role** with `REPLICATION` and read access to the captured tables. Installing the in-band DDL capture (below) additionally needs the right to create event triggers; without it the gateway runs in **degraded** mode, where a schema change must be announced with `rindle pg schema-hook` (below) or it stops the stream. The recipe is verified — installed at the current version, bound to this publication, with no un-announced change pending — at `preflight --source`, at `mint`, and every time a gateway boots. - Failover readiness is recorded, not gated: on PostgreSQL 17+ with a synchronous standby, a failover slot, `synchronous_commit = remote_apply` and `synchronized_standby_slots`, the gateway creates the generation's slot as a **failover slot** and a promoted standby carries it — you repoint the gateway and it resumes under the same generation, with no follower rebuild. Without every part of that recipe, a source failover means a new generation. The posture is decided once, at mint, from the live catalog: a generation minted without a synchronous standby stays a fencing one for life, so this is not something to turn on during an incident. For the supported provider-managed configuration, `--failover provider-managed` asks for that posture instead: the slot is created as a failover slot, you register it with the provider before the gateway's first boot (`rindle pg mint` prints the command), and after a switchover the gateway verifies the slot survived on the same cluster at or below its acked position and resumes. What it cannot verify is that the promoted node kept every transaction already archived; that is the provider's durability promise, recorded as such in the generation. ## Types Every column arrives with its exact Postgres type tag recorded in the follower's `_rindle_columns` sidecar; the mirror table's declared SQLite type follows this table. | PostgreSQL | Rindle type | Notes | | --- | --- | --- | | `bool` | boolean | | | `int2`, `int4`, `int8`, `oid` | integer (exact 64-bit) | never rounded through a double | | `float4`, `float8` | number | | | `numeric` | string | exact decimal text | | `text`, `varchar`, `char`, `citext`, `uuid`, `name` | string | | | `json`, `jsonb` | json | raw text | | `date`, `time`, `timetz`, `timestamp`, `timestamptz`, `interval` | string | Postgres text form | | `bytea` | string | hex | | arrays | json | JSON array text | | enums, domains, anything else | string | tagged, never rejected | This table describes capture and storage, not the full browser query contract. Postgres integer types map to exact `BIGINT`/`int64` columns. Maintained queries currently reject an `int64` column anywhere they require it: selected columns, primary keys, filters, ordering, or correlations. In particular, a table with an integer primary key can be mirrored successfully while its live query is refused. This is a current preview limitation, not a reason to cast stored keys through JavaScript numbers. A projection can avoid an unrelated int64 column only when the query does not otherwise need it. An `UPDATE` that leaves a large TOAST value untouched is applied as a **masked edit**: only the columns Postgres re-logged are written, the stored value survives, and the incremental engine still sees the full old/new row. ## Schema changes With DDL capture installed (`rindle pg prepare` does it), a migration tool's `ALTER` rides the stream in order, inside the transaction it committed in, and the followers apply the translated SQLite change at exactly that point — no new generation, no re-snapshot: | Postgres DDL | Follower effect | | --- | --- | | `CREATE TABLE` then `ALTER PUBLICATION … ADD TABLE` | new mirror table; existing rows are **backfilled** | | `ADD COLUMN` with a literal default (`'free'`, `0`, `true`, `NULL`) | `ALTER TABLE … ADD COLUMN … DEFAULT` | | `ADD COLUMN` with an expression default (`now()`, `gen_random_uuid()`, `nextval(…)`) | column added, then **backfilled** | | `RENAME COLUMN`, `RENAME TABLE` | rename | | `DROP COLUMN`, `DROP TABLE` | drop (a destructive change bounces live queries) | | `ALTER COLUMN … TYPE` | table rebuild with a cast | | `ALTER PUBLICATION … DROP TABLE` | mirror dropped | A backfill copies the affected rows at a consistent snapshot from a temporary replication slot and lands them exactly between the transactions around that snapshot. Until it completes, the column or table exists in the mirror but is **hidden** from `/schema`, the query catalog, and the wire schema, so no client sees a half-filled column. Lag during a backfill equals the copy's duration. What is **not** translated: a change to a row key or replica identity, and a DDL the renderer cannot express. Those stop the stream with a named reason; the fix is a new generation (`rindle pg mint` → `rindle pg cutover`), which is blue/green: the old generation keeps serving until the new one has a producer snapshot. **When the triggers cannot see the change** — Supabase fires no event trigger for `ALTER PUBLICATION`, and a host that refuses event triggers altogether runs degraded — announce it from inside the migration's own transaction, after the DDL: ```sql BEGIN; ALTER PUBLICATION rindle_pub ADD TABLE public.comment; SELECT rindle.schema_hook(); -- or: COMMENT ON PUBLICATION rindle_pub IS 'sync'; COMMIT; ``` The hook diffs the last schema the gateway was told about (`rindle.published_schema`) against the live catalog and emits the same in-band transition the triggers would have, at that commit. `rindle pg schema-hook --dry-run` prints both spellings; without `--dry-run` it runs the hook now, in its own transaction, which is only safe while no rows are written under the new shape. ## Storage and lag The gateway streams the initial copy into the archive one frame at a time. Its local log retains recently archived frames. `backup.retainBytes` controls that retention and defaults to 48 MiB. When unarchived data exceeds `backup.maxUnshippedBytes` (80 MiB by default), the gateway stops reading. Postgres retains the remaining WAL under the replication slot. The default settings bound ordinary local buffering to hundreds of MiB; this is not a total-disk guarantee for every workload. Allow headroom for frame sizes, in-flight uploads, logs, and operational files. The snapshot producer needs storage large enough for the dataset. A follower behind the local log can read the archived tail through the gateway. `backup.archiveCatchUpMaxBytes` defaults to 1 GiB. Beyond that gap, a follower restores from a newer snapshot if one exists. A value of `0` disables archive read-through. A follower restart resumes from its saved cursor. It restores at startup if it has no state or its cursor predates the archive's oldest snapshot. ## Operating it These are the lifecycle commands, not a complete copy-and-run deployment. `app` is an example source name; `--store s3` selects the S3 store configured through the backup environment. The PostgreSQL credentials, archive, gateway JSON, producer, and follower configuration must already agree. Commands that connect to Postgres require `--pg-user` and `--pg-database`. Supply the password through `RINDLE_PG_PASSWORD` or `--pg-password-file`. Configure TLS for your source; the default `require` mode encrypts the connection, while `verify-full` also verifies its certificate and hostname. The repository's `infra/postgres-authority/` drills provide complete local test configurations. ```sh rindle pg preflight --pg-host db --pg-user rindle --pg-database app --source app rindle pg prepare --pg-host db --pg-user rindle --pg-database app \ --tables public.issue,public.comment --source app rindle pg schema-hook --pg-host db --pg-user rindle --pg-database app --source app --dry-run rindle pg mint --pg-host db --pg-user rindle --pg-database app --source app --store s3 rindle-pg-gateway --config gateway.json # genesis, then stream rindle backup producer --source app --store s3 --scratch producer.db # snapshots rindle pg status --source app --store s3 rindle pg cutover --source app --store s3 --generation # blue/green; --rollback ``` The gateway acknowledges the replication slot only up to what the archive holds, so Postgres keeps WAL for anything not yet durable off-box. Size `max_slot_wal_keep_size` for the longest archive outage you will tolerate. ## Recovery consequences - **Gateway host lost** — the local log is a cache. A fresh host seeds from the archive's tail and Postgres redelivers from the slot; followers whose cursor is ahead of the archive re-bootstrap from it. No Postgres snapshot is taken. - **Slot invalidated** (`max_slot_wal_keep_size` exceeded, `wal_status = lost`) — the stream cannot resume; mint a new generation, which takes one consistent snapshot. - **Producer down** — followers keep streaming; only new-follower bootstrap ages. The backup plane's `ProducerSilent` / `SnapshotStale` alerts fire. - **Archive unreachable** — the gateway stops acking and, once `backup.maxUnshippedBytes` of frames wait locally, stops reading the stream; WAL accumulates on the primary under the slot until the archive returns, bounded by `max_slot_wal_keep_size`. Followers within `backup.retainBytes` of the head keep streaming; a follower further behind is served the archived tail once the archive returns (it restores only if its gap passed `backup.archiveCatchUpMaxBytes` with a newer snapshot to restore from). - **Source failover** — the gateway can resume on PostgreSQL 17+ when its configured failover-slot recipe holds; otherwise it requires a new generation. Before promoting, check on the standby that its copy of the slot is `synced` **and** its `confirmed_flush_lsn` has reached the gateway's acked LSN: the changes between the two are what a premature promotion loses. On a quiet source the copy may need `pg_log_standby_snapshot()` on the primary to catch up. Under the provider-managed posture the provider promotes; the gateway's reconnect check decides. - **Reconnect** — before every fresh replication connection (a restart, a lost stream) the gateway checks the endpoint on a normal connection: the same cluster it was admitted on, a primary, no timeline regression, this generation's slot present, not invalidated, a failover slot when the posture needs one, and not advanced past the archive. A standby or a slot still held by the old walsender waits; anything else halts with the reason in `/readyz` (`pump poisoned: replication slot does not exist …`), and the fix is a new generation. ## Managed providers These are the provider detectors and integration checks implemented by Rindle's current preflight. They are not a certification of live provider policies. | Provider | Detection or declaration | Integration checks | | --- | --- | --- | | Neon | `neon_superuser` role or `neon` extension | Reject a `-pooler` endpoint; report provider-specific slot and compute warnings | | Supabase | `supabase_admin` role or `supautils` | Reject detected pooled endpoints; check RLS capture privileges; verify DDL-capture capability | | PlanetScale for Postgres | `--provider planetscale`; hostname can supply a hint | Gate slot creation on the expected role; support the explicit provider-managed failover posture | | RDS / Aurora | Provider roles, logical-replication setting, or `aurora_version()` | Report replication privilege and source-failover requirements | | Cloud SQL / Azure Flexible Server | Provider roles or logical-decoding settings | Report source configuration and failover requirements | Before using a managed database, check its current official documentation for replication permissions, slot limits and expiry, endpoint selection, TLS, and failover guarantees. A warning emitted by the CLI can reflect an integration assumption that your provider or plan has since changed. The managed-provider nightly drill remains an open release gate. Local tests and provider detection do not establish that a hosted database has passed it. The full plan, invariants, and the open release gates live in the repository under `designs/406-DIRECT-POSTGRES-SOURCE-PARITY-PLAN.md`, the managed-provider design under `designs/407-MANAGED-POSTGRES-PROVIDERS-DESIGN.md`, and the drills under `infra/postgres-authority/`. --- [View this page on Rindle](https://rindle.sh/docs/postgres-source) --- # Deploying & scaling Rindle Deploy a standalone authority or a replicated fleet, then configure read scaling, durability, and recovery. Choose a data-tier deployment separately from your browser and API server. Rindle's SQLite authority has two profiles. **Standalone** runs one `rindled` process for SQL writes, reads, and live subscriptions. **Replicated** separates the write master from optional read followers. A replicated deployment needs at least one follower for live queries and browser sync. The [PostgreSQL-source preview](https://rindle.sh/docs/postgres-source) uses a gateway and followers while Postgres remains authoritative. Your API server connects through one URL and server-only token: ```sh RINDLE_URL=https://app.example.com RINDLE_DATABASE_TOKEN= ``` Standalone can expose that one origin directly. A fleet edge routes SQL and migrations to the master and follower protocols to the selected read replica. A query lease tells the browser which public WebSocket endpoint and follower-affinity ticket to use. The ticket controls placement, not authorization. ## Deployment shapes ### Standalone desktop or hosted micro Set `profile = "standalone"` in `rindle.ncl`, or run the thin container with `ROLE=standalone`. One source-less `rindled` serves authoritative SQL, mutations, migrations, reads, materializations, and subscriptions from the same file and origin. It is a good fit for a desktop/local app or a deliberately single-node hosted micro app whose recovery contract is provider volume snapshots. Standalone has no follower fan-out, HCTree/OCC writer pool, continuous journal backup, PITR, or automatic failover. Writes serialize. It does not scale out by adding a second writable daemon. Moving to a fleet is an export/import into a new HCTree store followed by a routing cutover; the wal2 file is not promoted in place. ### Master-only SQL The smallest replicated deployment is one `rindle-replicator`. Its HCTree database is the authoritative state and it serves the public SQL surface, interactive transactions, ordered migrations, and backup shipping. Use it with [`@rindle/sql-client`](https://rindle.sh/docs/sql-client), Drizzle, or `rindle sql` when live queries are not required. ### Master plus one follower Add one `rindled` follower for the complete synced-app stack. The follower restores from the master's lineage, tails its journal into a WAL2 SQLite database, and owns materialized queries and live subscriptions. The pair can share a host for local or small deployments. They remain separate processes. ### Read-scaled fleet One master can feed multiple followers. Live queries, one-shot maintained-query reads, and subscriptions scale across them. Ordinary `/v1/sql` requests still go to the write authority, and all writes retain the same sequencing point. A stable edge and signed affinity tickets keep each browser's lease and WebSocket on one follower and re-place it after a failure. This multi-follower shape is built for self-hosting. It is not currently a Rindle Cloud SKU. ## Writes: one order, topology-specific execution Standalone deliberately has one serialized wal2 writer. Its commit's captured changes cross the local IVM worker barrier and become visible without a replication wait. wal2 lets reader snapshots sit beside that writer, so a read-only public SQL transaction never parks writes: each one is backed by its own reader snapshot. A held snapshot pins wal2 checkpointing, so open read-only transactions are capped — `RINDLE_READ_TXN_SESSIONS` (default `4`); past the cap a begin answers `503` instead of queueing. In the replicated profile, "one write master" does not mean "one transaction at a time." The master pools HCTree `BEGIN CONCURRENT` connections. Disjoint transaction bodies can execute in parallel. HCTree validation assigns successful commits the `cid` that defines the journal order. - `RINDLE_WRITE_CONNECTIONS` controls open transaction capacity (default `8`). - `RINDLE_WRITE_THREADS` controls execution parallelism (default `1`). Raise it toward the cores available to the master. - Replayable pure-write conflicts retry inside the master. - A read-bearing conflict re-drives the authoritative mutator, because replaying only its generated SQL is unsound. Followers scale reads. Adding another write master is an application-level sharding decision, not a replica-count change. ## Local lifecycle `rindle dev` owns the normal development lifecycle in one command: ```sh rindle dev --migrate --gen shared/schema.gen.ts -- \ vite dev --port 3000 ``` It: 1. evaluates `rindle.ncl` once 2. starts one standalone daemon, or the replicated master, followers, and `rindle-dev-edge` 3. waits for readiness 4. applies and watches migrations 5. generates and watches schema 6. injects `RINDLE_URL` plus `RINDLE_DATABASE_TOKEN` 7. forwards signals 8. stops the fleet with the app command When you need to supervise only the data fleet, use `rindle up`. `rindle exec -- …` remains as a compatibility adapter that runs one command with bindings derived from `rindle.ncl`. For new projects, use `rindle dev`. The replicated profile runs the native development edge even for one follower, so local code exercises the same unified ingress and affinity path as production. The standalone profile binds read HTTP, write HTTP, and subscriptions directly to its one daemon. ## Durability and recovery Standalone desktop/local recovery is an app-owned SQLite snapshot (`VACUUM INTO`, the backup API, or an OS backup) on an explicit schedule. Hosted micro recovery is a completed provider volume snapshot restored onto a replacement volume and daemon. Its RPO is the configured snapshot interval; its RTO includes provisioning and restore downtime. Compute may scale to zero, but the persistent volume and snapshots do not. A product-initiated standalone snapshot must quiesce/close the writer. Before promising scheduled snapshots on a provider, qualify an active-wal2 snapshot → new-volume restore, run SQLite integrity checks, and prove the fresh-query correctness contract. Do not describe this as continuous backup or HA. In a fleet, a `backup` block makes the master ship logical journal segments and portable bases to an S3-compatible store. The durable manifest watermark bounds local journal garbage collection. Maintenance compacts segments, creates new bases, enforces retention, and scans for orphans. Restore exercises on a separate host must prove that the same generation can be restored. A new follower restores a WAL2 database from the latest portable base and replays the tail. A replacement master restores and promotes an HCTree leader from the same lineage. A follower older than retained history restores instead of requesting an unbounded replay. ## Rindle Cloud on OVH Rindle Cloud's Cloudflare-hosted control plane records billing, placement, DNS, and versioned intent. It commits desired state to the fleet repository. `rindle-poold` converges packed processes on the assigned OVH box and reports the applied generation. Cloudflare Tunnel publishes the stable `app-.rindle.cloud` ingress. The dashboard offers SQL and Sync plans for a SQLite authority, plus a PostgreSQL-source preview. SQL runs a master; Sync adds a read follower. The PostgreSQL preview keeps writes in your existing Postgres database and requires source preparation. It is a different authority topology, not a SQL/Sync upgrade. The dashboard does not currently expose arbitrary host, region, machine-size, volume-size, or follower-count controls. Consult the dashboard for current pricing and limits. See [Cloud quickstart](https://rindle.sh/docs/cloud-quickstart) for available plans and the [Postgres guide](https://rindle.sh/docs/postgres-source) for preview requirements. ## What is available | Shape | Self-hosted | Rindle Cloud | | --- | --- | --- | | **Standalone desktop/local** | ✅ built | Not applicable | | **Standalone hosted micro** | ✅ built; provider snapshot qualification is operator-owned | Not offered yet | | **Master-only SQL** | ✅ built | ✅ SQL plan | | **Master + one follower** | ✅ built | ✅ Sync plan | | **Multiple read followers** | ✅ built | Not offered yet | | **Regional follower placement** | ✅ operator-controlled | Not offered yet | | **Postgres-sourced** | Preview: gateway, archive, snapshot producer, and followers; release gates open | PostgreSQL-source preview; preparation required | ## Run it yourself, or have us run it - **Self-host** — run standalone with your snapshot/restore contract, or operate the fleet master, followers, edge, backup lineage, and restore drills in infrastructure you control. - **Rindle Cloud** — provision the SQL or Sync stack, or follow the PostgreSQL-source preview workflow. The control plane manages its declared serving processes. Self-hosted and hosted SQLite deployments use the same schema, SQL client, API server, browser client, and migrations. A PostgreSQL source keeps its own write and migration workflow. The dashboard cannot convert an existing SQL/Sync app to Postgres or a Postgres-source app to SQL/Sync in place. ## Next steps - [Cloud quickstart](https://rindle.sh/docs/cloud-quickstart) — choose SQL, Sync, or the PostgreSQL-source preview. - [Connect your app](https://rindle.sh/docs/cloud-connect) — use one URL and one token. - [The three-tier architecture](https://rindle.sh/docs/architecture) — the application shape around the data tier. - [Run the daemon](https://rindle.sh/docs/daemon) — standalone authority and follower postures in full. --- [View this page on Rindle](https://rindle.sh/docs/deploy) --- # Cloud quickstart Provision managed Rindle SQL or a synced Rindle backend, then connect with one URL and one server-only token. [Rindle Cloud](https://cloud.rindle.sh) hosts the Rindle data tier. Choose a SQL database, add live queries with Sync, or connect a PostgreSQL source through the preview integration. Your application connects through one stable URL. You deploy your browser application and API server on your chosen app host. This page gets you from signup to a running database. [Connect your app](https://rindle.sh/docs/cloud-connect) covers the SDK wiring. ## 1 · Sign in and add a card Create an account at [cloud.rindle.sh](https://cloud.rindle.sh) and add a payment method. The dashboard offers these managed topologies. Review its plan quote when provisioning: | Plan | Topology | Includes | | --- | --- | --- | | **SQL** | one HCTree write master | SQL, interactive transactions, migrations, streaming backup | | **Sync** | the same master plus one read follower | everything in SQL, plus live queries and subscriptions | | **Postgres preview** | capture gateway and Rindle read follower | live queries over your PostgreSQL data; writes stay in Postgres | Each serving process uses a fixed **5 GB database · 1 CPU · 512 MB** slot. The managed topology includes streaming backup. ## 2 · Choose the write authority Choose **SQL** for an ordinary database used through [`@rindle/sql-client`](https://rindle.sh/docs/sql-client), Drizzle, or `rindle sql`. Choose **Sync** for live queries and subscriptions, including server read models and synced browser apps. These two plans use Rindle as the write authority. Choose **Postgres preview** when PostgreSQL must remain authoritative. The form collects the source connection and capture credentials; the dashboard supplies a `rindle pg prepare` command for your source database. Read the [Postgres source guide](https://rindle.sh/docs/postgres-source) before provisioning. Its type, mutation-replay, and operational limits still apply to managed deployment. The Sync follower runs beside the master on the selected fleet box. Managed regional placement and multi-follower read scaling are not offered yet. The [deployment guide](https://rindle.sh/docs/deploy#what-is-available) distinguishes today's hosted menu from the larger self-hosted topology. ## 3 · Provision Submit the form. The dashboard shows the app moving from `provisioning` to `active` as the requested processes become ready. Deployment details show the requested configuration and the latest state reported by the host. If fleet capacity is temporarily full, the app remains queued and convergence retries automatically. ## 4 · Connect Once the app is active, the **Connect** panel supplies exactly two values: ```sh RINDLE_URL=https://app-.rindle.cloud RINDLE_DATABASE_TOKEN= ``` Keep both values on the server. The one URL routes SQL and migration traffic to the HCTree master and, on the Sync plan, read, lease, and subscription traffic to the follower. For a Postgres-sourced app, this connection supplies Rindle reads and subscriptions; writes still go to your own Postgres. Browsers call your API server and discover their public WebSocket endpoint from a query lease. They never receive the database token. Continue with [Connect your app](https://rindle.sh/docs/cloud-connect) for SQL and Sync examples. ## CLI workflow A one-follower `rindle.ncl` can provision the Sync plan directly: ```sh rindle login rindle deploy --migrate ``` The command waits for the managed app, stores its non-secret binding in `.rindle/cloud.json`, and can apply local migrations through the Cloud control plane. For a master-only SQL app, create it in the dashboard and bind the project before running remote migrations: ```sh rindle link app_… rindle migrate apply --cloud ``` ## Next steps - [Connect your app](https://rindle.sh/docs/cloud-connect) — use the unified URL and token. - [Scale & operate](https://rindle.sh/docs/cloud-scaling) — inspect convergence, switch plans, or reactivate an app. - [Deploying & scaling](https://rindle.sh/docs/deploy) — compare local, self-hosted, and managed shapes. - [SQL client](https://rindle.sh/docs/sql-client) — use Rindle as a fetch-native SQL database. --- [View this page on Rindle](https://rindle.sh/docs/cloud-quickstart) --- # Connect your app to Rindle Cloud Connect SQL services and synced apps to Rindle Cloud with one ingress URL and one server-only database token. After you provision a database with the [Cloud quickstart](https://rindle.sh/docs/cloud-quickstart), connect your server code with its URL and database token. This page covers the SQL and Sync plans. A [Postgres-sourced app](https://rindle.sh/docs/postgres-source) uses the Rindle connection for reads and subscriptions, with a separate connection to its Postgres write authority. These are the same two values that `rindle dev` supplies for local development. Use the SQL client for ordinary database requests. On the Sync plan, an API server can also authorize named queries and mutations for browser clients. ## The two values on your dashboard Open the app's **Connect** panel: | Value | Purpose | | --- | --- | | `RINDLE_URL` | Unified HTTPS ingress for SQL, migrations, reads, leases, and subscriptions | | `RINDLE_DATABASE_TOKEN` | Database-wide bearer for trusted server code | An app URL looks like `https://app-.rindle.cloud`. The edge routes each protocol to the right process: writes reach the HCTree master. Sync reads and live-query traffic reach its follower. You do not configure master, follower, control-plane, or WebSocket addresses separately. The token is a per-app secret. Put it in the API server's secret store, never source control or browser-visible environment variables. ## SQL services and scripts Use [`@rindle/sql-client`](https://rindle.sh/docs/sql-client) for a SQL-plan app or ordinary server-side SQL against either plan: ```ts import { createSqlClient } from "@rindle/sql-client"; export const db = createSqlClient({ url: process.env.RINDLE_URL!, authToken: process.env.RINDLE_DATABASE_TOKEN!, }); const rows = await db.execute({ sql: "select id, title from issue where status = ?", args: ["open"], }); ``` The CLI uses the same values: ```sh rindle sql "select count(*) from issue" rindle sql --file scripts/backfill.sql ``` Several statements passed to `rindle sql` are applied as one atomic batch. For versioned schema or data changes, use [`rindle migrate apply`](https://rindle.sh/docs/schema#migrations) instead. ## Synced app API server On the Sync plan, continue the [manual synced-app quickstart](https://rindle.sh/docs/synced-app-quickstart) or use the [scaffold](https://rindle.sh/docs/create-rindle). Those guides define the schema, named query registry, shared mutators, HTTP adapter, and browser client. Set these environment variables on your deployed API server: ```sh RINDLE_URL=https://app-.rindle.cloud RINDLE_DATABASE_TOKEN= ``` The quickstart already reads both values and supplies them to `createRindleApiServer` as `rindle: { url, token }`. It also converts shared mutators with `sharedApiMutators`. Keep that wiring and replace the demonstration identity with your application's authenticated session. `@rindle/api-server` provides transport-independent handlers. Mount them in the server framework you deploy; [The API server](https://rindle.sh/docs/api-server) shows the boundary. Cloud hosts the data tier, not this application HTTP adapter. ## Browser client Keep the browser client pointed at your application's API. The quickstart uses `api: { url: "" }` for same-origin `/api/rindle/*` routes. If the API has a separate origin, configure its URL, credentials, and CORS policy for that deployment. An authorized query lease returns the public WebSocket endpoint and a signed follower-affinity ticket. The ticket keeps lease and subscription traffic on the same follower. It is placement metadata; your API still authorizes each query. The browser never receives the database token. ## Local and Cloud use the same contract | | Local development | Rindle Cloud | | --- | --- | --- | | `RINDLE_URL` | injected by `rindle dev` | copied from **Connect** | | `RINDLE_DATABASE_TOKEN` | injected by `rindle dev` | copied from **Connect** | | Data tier | local master, follower, and `rindle-dev-edge` | packed OVH processes behind Cloudflare Tunnel | | Browser setup | API URL only | API URL only | For the same SQL or Sync topology, your schema, queries, mutators, SQL calls, and browser setup do not change between local and managed environments. Postgres requires its own source and mutation-backend setup. ## Migrations After `rindle deploy` or `rindle link` has written `.rindle/cloud.json`, apply the same ordered migration files remotely: ```sh rindle migrate status --cloud rindle migrate apply --cloud ``` The binding is safe to commit. It identifies the app but contains no database token. The CLI authenticates the Cloud proxy with your `rindle login` session. ## Next steps - [SQL client](https://rindle.sh/docs/sql-client) — queries, sessions, transactions, and Drizzle. - [The API server](https://rindle.sh/docs/api-server) — named queries and authoritative mutators. - [The browser client](https://rindle.sh/docs/client) — optimistic writes, live views, and rebase. - [Scale & operate](https://rindle.sh/docs/cloud-scaling) — plan changes and fleet convergence. --- [View this page on Rindle](https://rindle.sh/docs/cloud-connect) --- # Scale & operate on Rindle Cloud Read managed fleet convergence, switch between SQL and Sync, and recover a suspended Rindle Cloud app. Use the Rindle Cloud dashboard to inspect deployment status, change between SQL and Sync plans, or reactivate a suspended database. The dashboard distinguishes the requested deployment from the processes currently running. ## Read the dashboard - **Status** — `provisioning`, `active`, `suspended`, `deleting`, or `deleted`. - **Topology** — the SQL master-only or Sync master-plus-follower shape requested by the current desired version, or the Postgres gateway-and-follower preview shape. - **Observed** — the assigned OVH box, desired/applied fleet generations, and each process unit's reported state. - **Connect** — one `RINDLE_URL` and one server-only `RINDLE_DATABASE_TOKEN`. - **Plan** — the current SQL, Sync, or Postgres preview plan and its quote. An active app is converged when the box reports an applied generation at or beyond the desired generation. A reconcile failure remains visible and retries automatically. A capacity shortage leaves the app queued until a slot is available. ## Change plans The dashboard's **Change plan** control switches the desired topology: | Plan | Serving processes | | --- | --- | | **SQL** | HCTree master | | **Sync** | HCTree master + one read follower | Moving SQL → Sync adds a follower, which restores the master's current lineage and then tails its ordered journal. Moving Sync → SQL removes that follower. The app's URL, token, master, data, and backup lineage stay the same. A Postgres-sourced app cannot switch to SQL or Sync, and an existing SQL or Sync app cannot switch to Postgres. That would change the write authority. Create a separate app for that integration; changing the plan does not migrate its data. A Postgres app can update its source settings within the preview [recovery rules](https://rindle.sh/docs/postgres-source#recovery-consequences). Every serving process is currently a fixed **5 GB database · 1 CPU · 512 MB** slot. The managed product does not currently expose CPU, RAM, volume, region, or follower-count controls. For those deployment choices, [self-host the built topology](https://rindle.sh/docs/deploy). ## Suspend and reactivate A failed payment suspends the app and unschedules its serving processes without discarding its volume or backup lineage. Once billing is resolved, use **Reactivate** to charge the current plan and converge the processes again. Deletion of an OVH fleet app currently requires operator action. The dashboard says so before accepting a request. ## Billing The dashboard quotes the selected plan and its billing terms. Review those terms when changing plans or reactivating an app. Resource topology and application connection settings are documented separately here. ## What scaling means today Rindle's engine supports one HCTree master feeding multiple read followers, but Rindle Cloud exposes fixed SQL and Sync shapes plus a Postgres source preview. The Postgres preview uses a gateway in place of the Rindle write master. Managed regional placement and a multi-follower fleet are not dashboard controls. The current SQL and Sync plans use the same application URL and connection options. ## Next steps - [Connect your app](https://rindle.sh/docs/cloud-connect) — use the unified managed connection. - [Deploying & scaling](https://rindle.sh/docs/deploy) — current hosted and self-hosted shapes. - [Run the daemon](https://rindle.sh/docs/daemon) — the read follower operated by the Sync plan. - [Is Rindle for you?](https://rindle.sh/docs/compare) — where the single-ordering-master model fits. --- [View this page on Rindle](https://rindle.sh/docs/cloud-scaling) --- # Testing your app Test query definitions and mutators, then validate optimistic reconciliation and synchronization with real clients. Use tests at three levels: application logic, local query behavior, and the real sync path. Each level answers a different question. | Test | What it proves | What it does not prove | | --- | --- | --- | | A mutator with a recorded operation log | Validation and emitted write operations | Database behavior or authorization | | The WASM engine with local data | Query results and optimistic predictions | Server acceptance or convergence | | Two clients with the API server and data tier | Authorization, rejection, and synchronization | Every application query or UI state | The runnable examples here use a small independent issue schema. They do not import helpers from another guide or require a running server. After running them, replace the fixture with your application's shared definitions. ## Prepare the test project Use Node 22.18 or later. In an empty directory, create `package.json`: ```json { "name": "rindle-app-tests", "private": true, "type": "module", "scripts": { "test": "tsc --noEmit && node --test test/*.test.ts" } } ``` Install the dependencies: ```sh pnpm add @rindle/client @rindle/wasm @rindle/optimistic zod pnpm add -D typescript @types/node ``` Add the TypeScript configuration: ```json // tsconfig.json { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "allowImportingTsExtensions": true, "noEmit": true, "strict": true, "skipLibCheck": true, "types": ["node"] }, "include": ["test/**/*.ts"] } ``` Node runs the TypeScript after removing type annotations. It does not typecheck the files. The `tsc` step performs that check first. ## Define the fixture The fixture supplies every table, query, and mutator used by the tests: ```ts // test/fixture.ts import { boolean, createSchema, defineFragment, defineMutators, defineQuery, newQueryBuilder, string, table, } from "@rindle/client"; import { z } from "zod"; export const issue = table("issue").columns({ id: string(), title: string(), closed: boolean(), ownerId: string(), }).primaryKey("id"); export const schema = createSchema({ tables: [issue] }); export const q = newQueryBuilder(schema); export const IssueTitleFragment = defineFragment(issue, (row) => row.select("id", "title"), ); export const openIssuesQuery = defineQuery("openIssues", () => q.issue.where.closed(false).orderBy("id", "asc").include(IssueTitleFragment), ); const { shared } = defineMutators(schema); export const mutators = { createIssue: shared( z.object({ id: z.string().min(1), title: z.string().min(1).max(200) }), function* (tx, args, ctx) { const title = args.title.trim(); if (title.length === 0) throw new Error("Title must contain text"); yield tx.insert("issue", { id: args.id, title, closed: false, ownerId: ctx.user, }); }, ), setClosed: shared( z.object({ id: z.string(), closed: z.boolean() }), function* (tx, args) { yield tx.update("issue", args); }, ), }; ``` `createIssue` trims the title inside the mutator body. It obtains `ownerId` from the execution context, not the input arguments. This fixture illustrates application behavior. It is not a complete ownership policy. ## Record a mutator's operations `driveMutationSync` runs the generator against an executor that you provide. This executor records writes and supplies empty read results: ```ts // test/mutators.test.ts import { test } from "node:test"; import assert from "node:assert/strict"; import { driveMutationSync, isoTx } from "@rindle/client"; import type { MutationOp } from "@rindle/client"; import { mutators } from "./fixture.ts"; test("createIssue trims the title and uses the acting user", () => { const operations: MutationOp[] = []; const generator = mutators.createIssue( isoTx, { id: "i1", title: " Ship it " }, { user: "alice" }, ); driveMutationSync(generator, { apply: (operation) => { operations.push(operation); }, read: () => undefined, query: () => [], }); assert.deepEqual(operations, [{ kind: "insert", table: "issue", row: { id: "i1", title: "Ship it", closed: false, ownerId: "alice" }, }]); }); test("the argument schema rejects an invalid title type", () => { assert.throws(() => mutators.createIssue.args.parse({ id: "i1", title: 42 })); }); ``` A direct generator call does not parse its argument schema. Neither does the optimistic backend automatically parse a shared mutator's schema. The API server parses untrusted arguments before execution. The separate validation test exercises that parser explicitly. For a mutator that reads data, supply representative rows through `read` and `query`. Cover each branch of the mutator's guards. An operation log does not prove that a SQL transaction or local engine accepts those writes. ## Exercise a maintained query This test uses the real WASM engine. `createWasmStore` initializes it in Node and reads the WASM bytes from the installed package. ```ts // test/queries.test.ts import { test } from "node:test"; import assert from "node:assert/strict"; import { createWasmStore } from "@rindle/wasm"; import { openIssuesQuery, schema } from "./fixture.ts"; test("the open issue list follows inserts, edits, and removals", async (context) => { const store = await createWasmStore(schema); const view = store.materialize(openIssuesQuery()); context.after(() => view.destroy()); const first = { id: "i1", title: "First", closed: false, ownerId: "alice" }; const second = { id: "i2", title: "Second", closed: false, ownerId: "bob" }; await store.write((tx) => { tx.add("issue", second); tx.add("issue", first); }); assert.deepEqual(view.data, [ { id: "i1", title: "First" }, { id: "i2", title: "Second" }, ]); await store.write((tx) => tx.edit("issue", first, { ...first, closed: true })); assert.deepEqual(view.data, [{ id: "i2", title: "Second" }]); await store.write((tx) => tx.remove("issue", second)); assert.deepEqual(view.data, []); }); ``` The test checks ordering, filtering, fragment projection, and changes to an existing view. It does not reconstruct the view after each write. For a single result read, `store.readOnce(query)` creates and releases a temporary view. ## Exercise a prediction before confirmation An optimistic source connects the backend to server data and mutation acknowledgements. This test source deliberately sends neither. It isolates the local prediction, which changes views before `mutate` returns. ```ts // test/optimistic.test.ts import { test } from "node:test"; import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; import { defineMutators } from "@rindle/client"; import type { NormalizedEvent, OptimisticSource, ProgressFrame, QueryId } from "@rindle/client"; import { initWasm } from "@rindle/wasm"; import { createOptimisticStore } from "@rindle/optimistic"; import { mutators, openIssuesQuery, schema } from "./fixture.ts"; await initWasm(); class SilentSource implements OptimisticSource { registerQuery(): void {} unregisterQuery(): void {} pushMutation(): Promise { return Promise.resolve(); } onNormalized(_handler: (queryId: QueryId, event: NormalizedEvent) => void): void {} onProgress(_handler: (frame: ProgressFrame) => void): void {} } const { shared } = defineMutators(schema); const registry = { ...mutators, failAfterInsert: shared( mutators.createIssue.args, function* (tx, args, ctx) { yield tx.insert("issue", { id: args.id, title: args.title, closed: false, ownerId: ctx.user, }); throw new Error("Deliberate test failure"); }, ), }; test("predictions update views synchronously and a failed body rolls back", (context) => { const { store, mutate } = createOptimisticStore(schema, new SilentSource(), registry, { clientID: randomUUID(), user: () => "alice", }); const view = store.materialize(openIssuesQuery()); context.after(() => view.destroy()); mutate.createIssue({ id: "i1", title: "Ship it" }); assert.deepEqual(view.data, [{ id: "i1", title: "Ship it" }]); mutate.setClosed({ id: "i1", closed: true }); assert.deepEqual(view.data, []); assert.throws( () => mutate.failAfterInsert({ id: "i2", title: "Must not appear" }), /Deliberate test failure/, ); assert.deepEqual(view.data, []); assert.equal(view.resultType, "unknown"); }); ``` The source accepts outgoing calls without confirming them. Those writes remain pending, and the named query's coverage remains `unknown`. This test does not simulate a server rejection or a rebase. Its final assertion prevents a local prediction from being mistaken for server confirmation. Run all four tests: ```sh pnpm test ``` Each test creates a fresh store and destroys its materialized view. The silent source opens no sockets or timers. For tests using `createRindleClient`, also call `client.close()` during teardown. Use distinct client IDs when multiple clients share one authority. ## Cover the real sync path For integration tests, use a test database and the app's real API server. The [synced-app quickstart](https://rindle.sh/docs/synced-app-quickstart) defines both server and client startup. [`rindle dev`](https://rindle.sh/docs/rindle-cli) starts the local data tier and supplies its connection environment to your app processes. A complete sync test covers these steps: 1. Create two authenticated clients with distinct client IDs. 2. Retain the same named query on both clients. 3. Wait for complete coverage before checking the initial result. 4. Mutate through client A and assert its immediate local prediction. 5. Wait for client B to receive the accepted change. 6. Cause a known server rejection and assert the rejection callback and corrected local result. 7. Release both views and close both clients in teardown. Use a deadline for every wait. A short sleep does not establish that synchronization is complete. Record rejection reasons so a timeout does not hide an authorization or validation error. Also cover a user who cannot read the query, reconnect behavior, and an empty authoritative result. These tests can use `node --test` or your existing runner. They need real service startup and cleanup in CI. The repository's [issue-tracker end-to-end test](https://github.com/rindle-sh/rindle/blob/main/apps/example-issue-tracker/test/smoke.e2e.ts) shows a real two-client harness with bounded waits and process cleanup. For UI tests, distinguish pending mutations from query completeness. [Rejected writes](https://rindle.sh/docs/rejected-writes) describes the failure behavior, and [fragments](https://rindle.sh/docs/fragments) describes the React read boundaries. --- [View this page on Rindle](https://rindle.sh/docs/testing) --- # Devtools Inspect mutations, live queries, and changes during development with Rindle devtools. Rindle's in-browser devtools are split into two packages: - **`@rindle/devtools`** - a framework-agnostic core that attaches to a running `createRindleClient` app and builds a read-only inspection model. - **`@rindle/react-devtools`** - the floating React panel over that core. Use both in development. The core also accepts a compatible `{ store, backend }` composition; the available inspection details depend on its backend. They inspect state the client already holds. They do not add a data-tier connection and do not hold your database token. They must not ship to production. ## Install ```bash pnpm add -D @rindle/devtools @rindle/react-devtools # or npm i -D @rindle/devtools @rindle/react-devtools ``` Apps scaffolded with [`create-rindle`](https://rindle.sh/docs/create-rindle) already include this wiring. ## Attach the client This example continues the [manual quickstart](https://rindle.sh/docs/synced-app-quickstart). Append this block after its exported `app` in `src/rindle-client.ts`: ```ts if (import.meta.env.DEV) { void import("@rindle/devtools").then(({ attachDevtools }) => attachDevtools(app)); } ``` The dynamic import matters: in a production Vite build, `import.meta.env.DEV` is statically false, so the devtools core is dropped from the production bundle. ## Mount the React panel Mount the panel once near the root. In an SSR app, mount it only after the first client effect so the server markup and hydration markup match: ```tsx // src/devtools.tsx import { lazy, Suspense, useEffect, useState } from "react"; const Panel = import.meta.env.DEV ? lazy(() => import("@rindle/react-devtools").then((m) => ({ default: m.RindleDevtools }))) : null; export function DevTools() { const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); if (!Panel || !mounted) return null; return ( ); } ``` Import `DevTools` from `./devtools.tsx` and render `` once in your application root. The panel auto-discovers the most recently attached client through the devtools hub. It starts as a small Rindle launcher in the corner of the page. ## What it shows - **Mutation timeline** - each optimistic mutation from invoke to pending to confirmed or dropped, including snap-back highlights when the server result diverges from the prediction. - **Queries inspector** - every live materialized view, its result type, row count, AST, sample rows, and whether pending mutations touch its tables. - **Delta stream** - the raw incremental change tape for each query: `Add`, `Remove`, `Edit`, and nested child deltas. This targets the optimistic loop: what did the browser predict, what did the authority accept, and how did the live views rebase? ## Core-only use The core has no DOM assumptions. If you are not using React, attach the client and render the state yourself: ```ts import { app } from "./rindle-client.ts"; const { attachDevtools } = await import("@rindle/devtools"); const core = attachDevtools(app); const unsubscribe = core.subscribe(() => { const { timeline, queries, deltas } = core.getState(); console.log({ timeline, queries, deltas }); }); // Later: unsubscribe(); core.detach(); ``` `@rindle/react-devtools` re-exports the core helpers for convenience, but the data model lives in `@rindle/devtools`. ## Production rules Keep both packages dev-only and behind static dev gates. The recommended pattern is: - dynamically import `@rindle/devtools` only after `createRindleClient` resolves. - dynamically import `@rindle/react-devtools` only when `import.meta.env.DEV`. - in SSR apps, render no panel on the server or hydration pass. - never pass `RINDLE_DATABASE_TOKEN` or other server credentials to devtools. The client exposes small read-only inspection hooks for the core. Nothing runs unless you import the package and call `attachDevtools`. ## Next steps - [The browser client](https://rindle.sh/docs/client) - where `createRindleClient` is configured. - [Server rendering](https://rindle.sh/docs/ssr) - how to keep the panel client-only in an SSR app. - [Scaffold with create-rindle](https://rindle.sh/docs/create-rindle) - a starter with devtools already wired. --- [View this page on Rindle](https://rindle.sh/docs/devtools) --- # Troubleshooting Diagnose missing sync, schema mismatches, optimistic corrections, and authorization errors. This page covers common problems in a synced application: missing subscriptions, schema mismatches, rejected writes, and connection errors. It assumes the optimistic [browser client](https://rindle.sh/docs/client) and [API server](https://rindle.sh/docs/api-server) are configured. For standalone engine errors, start with the [query reference](https://rindle.sh/docs/supported-queries-ts) and the [change model](https://rindle.sh/docs/change-model). ## The rules that keep an app correct These are not style preferences. Break one and the app is wrong in a way tests don't always catch immediately. 1. **SQL is the source of truth. The TS schema is generated.** Evolve the schema by **adding** an ordered migration (pure DDL for schema, pure DML for data), and never hand-edit `schema.gen.ts` — see [Schema & migrations](https://rindle.sh/docs/schema). 2. **A mutator is one isomorphic body, run on both tiers.** Write it once with `shared(args, gen)`. Add a hand-written server entry only for authority the client must *not* predict — see [Isomorphic mutators](https://rindle.sh/docs/mutators). 3. **Mutators must be deterministic and replayable.** No `Date.now()`, no `Math.random()`, no I/O — ids and timestamps arrive as args, and the acting user is `ctx.user`, never a client-supplied arg. 4. **Only `(name, args)` crosses the wire.** Client-built ASTs and client-computed effects never become authority. The server parses untrusted args through the mutator's `.args` schema and each query's validator. 5. **Remote subscriptions must be named.** Only a `defineQuery` value opens a server subscription. A bare builder query resolves locally. 6. **Subscribe to windows, not whole tables.** Order + `limit`, and raise the limit for "load more". 7. **The database token is server-only.** Only trusted code receives `RINDLE_URL` + `RINDLE_DATABASE_TOKEN`. The browser receives an authorized lease, public WebSocket endpoint, and placement ticket. 8. **Keep `*.queries.ts` modules framework-free.** The browser, the API authority, and any SSR loader all import them. No component imports. Connection failures and unsupported query shapes also appear in the symptoms below. Start with the section that matches the observed behavior. ## Nothing syncs / a query never leaves "loading" - **The query isn't named.** Only a `defineQuery` value opens a **server** subscription. A bare `store.query.
    .where…` builder resolves **locally only** — it renders off already-synced rows and never pulls new data. Wrap it in `defineQuery`, call `myQuery(args)`, and register it on the server. - **The query isn't registered on the server.** Add it to the `registerQueries([...])` list in your API server. An unregistered name can't resolve to an AST. - **The lease has no usable `wsEndpoint`.** Remove old browser-side `daemon.wsUrl` configuration. Configure the API server with `rindle: { url, token }`. It derives the public socket from the unified ingress (or from an explicit server-side `rindle.wsUrl`) and returns it on the lease. - **`api.url` doesn't reach your API server.** Check the dev-server proxy (e.g. Vite's `server.proxy["/api"]`) points at the API server's port. - **The daemon isn't running / migrations weren't applied.** `rindle status` and `rindle migrate status`. ## `schema.gen.ts` errors / types don't match the DB - **Someone hand-edited `schema.gen.ts`.** It's generated and overwritten. Change the **SQL migration** instead, then re-run `rindle schema gen` (or let `rindle dev --gen …` regenerate on change). - **Forgot to regenerate after a migration.** Run `rindle schema gen --out shared/schema.gen.ts`. The normalized client checks advertised columns and primary keys, but cannot regenerate application code or validate every declared type refinement. - **A column is the wrong kind** (e.g. a boolean reads as `number`). SQLite kept the declared name — declare it `BOOLEAN`/`JSON`, not bare `INTEGER`/`TEXT`. - **A migration was rejected.** `RENAME` and column **type changes** are not supported — expand instead (add the new column/table, move writes, then `DROP` the old one). `blob` is also refused. Drops themselves are supported and print a `[destructive]` notice. (`BIGINT`/`INT8` are accepted — they declare the exact int64 plane. Live queries touching such a column are refused until the browser bigint lane ships, including an int64 primary key. Projecting other columns helps only when the query does not need the int64 column elsewhere.) See [Schema & migrations](https://rindle.sh/docs/schema). - **A migration mixes DDL and DML.** Split it into ordered files: add the schema in one file, then seed or backfill it with `INSERT` / `UPDATE` / `DELETE` in the next. Reads, PRAGMAs, and transaction-control statements are not migration steps. - **A data migration is too large.** One file can capture at most 8,191 user-row changes and 64 MiB. Split the backfill into explicit key ranges. Each file is its own atomic, checksum-bound migration. ## Optimistic writes flicker, double-apply, or drift after rebase - **A mutator is non-deterministic.** It re-runs on every rebase — remove `Date.now()`, `Math.random()`, and any I/O. Generate ids/timestamps at the **callsite** and pass them as args ([the determinism rules](https://rindle.sh/docs/mutators#the-determinism-rules)). - **A server override drifted from the shared body.** Shared code can still observe different rows on the browser and server. Check missing local coverage and server-only policy first. Use `runSharedMutation` when an override should reuse the shared body, and test both accepted and rejected outcomes. A correction can be expected behavior. - **A read-dependent mutator was folded.** A mutator that reads (`yield tx.row` / `tx.query`) or isn't absorbing (e.g. `increment`) must **not** use `.folded(...)`. The folded path throws for readers. Route non-absorbing mutators through plain `mutate`. ## A write is silently ignored (no error, nothing changes) - **Accepted-but-no-op is by design.** If a server op matches no row (e.g. a raw `DELETE … WHERE id = ? AND ownerId = ?` for a non-owner), the write is accepted and the optimistic change rebases away. If you meant to *reject*, `throw` in the mutator body (or a server guard) instead — a hard reject fires `onRejected`. The two shapes are contrasted in [the API server](https://rindle.sh/docs/api-server#driving-the-shared-mutators). - **Args failed server validation.** The shared mutator's `.args` schema parses the untrusted wire args before the body runs. If `parse(raw)` throws, it's a hard reject — surface it via `onRejected`. - **A constraint violation reached the database.** A duplicate insert (`UNIQUE constraint failed: tag.normalizedName`) is reported as a *rejection*: the API server demotes a constraint violation to a business rejection, so `lmid` advances, the prediction snaps back, and `onRejected` carries SQLite's own message. Check the constraint in the mutator body if you want a friendlier reason — but it can no longer wedge the queue. - **The mutate route is erroring, not rejecting.** Check the `/mutate` response in the network tab. Anything that is *not* a verdict — a 503 from a restarting daemon, a bad token, a disk error — is retried with backoff, so the prediction stays applied and later mutations queue behind it. That is deliberate (a network blip must not drop a write), and it is no longer silent: the client `console.error`s each failed flush and calls `onMutationError`. ## A query throws `BuildError` when it materializes You hit an unsupported shape. Check [Supported query shapes](https://rindle.sh/docs/supported-queries-ts) — common ones: root `count()` mixed with `select`/`sub`/`orderBy`; a low-pass parent-by-child-count `having`; an `exists` carrying `start` or a nested `sub`; `min`/`max`. The aggregate surface differs between Rust and TypeScript; use the matrix for your API rather than assuming they expose identical methods. ## Auth / security smells - **`RINDLE_DATABASE_TOKEN` reached the browser.** It is a database-wide credential. Keep it in the API server's secret store. The browser must know only your API URL and the short-lived lease data that API returns. - **Trusting a client-supplied owner/author.** Identity is **off-wire** — the actor is `ctx.user` (the server injects its authenticated principal), never an `owner`/`author` arg. The shared body already reads `ctx.user`, so the server's `sharedCtx` is the single place identity enters. - **Not validating args on the server.** The client's prediction is a guess. Every shared mutator carries its `.args` schema. `sharedApiMutators` parses the untrusted wire args through it before the body runs. A hand-written override must parse too. ## SSR / wasm boot errors - **wasm constructed during server render.** Never construct the optimistic client during SSR/prerender. Defer: lazily `import("@rindle/optimistic")` + `import("@rindle/wasm")` on the client and memoize the boot promise — see [Server rendering](https://rindle.sh/docs/ssr). [`create-rindle`](https://rindle.sh/docs/create-rindle) apps ship a browser boot function in `src/rindle-client.ts`. ## A restart loses live queries Expected — `rindled` keeps no durable materialization state. Wire `onBootId` on the `HttpRindleDaemonClient` and re-assert pins (`api.assertPins()`) when it fires — see [pinned queries](https://rindle.sh/docs/api-server#pinned-queries-the-one-shot-read). ## SQL points at the wrong service Use the application ingress, not an internal master port or legacy replicator URL: ```sh RINDLE_URL=https://app-… RINDLE_DATABASE_TOKEN=… rindle sql "select 1" ``` Application code uses the same values with `createSqlClient({ url, authToken })`. A `409` naming the write-master means `RINDLE_URL` points at a fleet follower, which refuses the whole `/v1/sql` surface; a `404` for `/v1/sql` means it points at a control endpoint or some other service instead of the unified edge. ## `ECONNREFUSED` on `:7600` / `:7611` / `:7650` after upgrading Local ports are no longer fixed. Each project gets its own 100-wide block, chosen from the path of the directory holding `rindle.ncl`. Several Rindle projects — or several git worktrees of one project — can therefore run at the same time. `:7600` and friends are not your fleet's ports any more. See [Running several projects at once](https://rindle.sh/docs/rindle-cli#running-several-projects-at-once). Take the URLs from the environment `rindle dev` injects, or from `rindle.json`'s `bindings` — never a literal: ```ts // not: process.env.RINDLE_DAEMON_URL ?? "http://127.0.0.1:7600" const daemonUrl = process.env.RINDLE_DAEMON_URL; if (!daemonUrl) throw new Error("RINDLE_DAEMON_URL is required"); ``` A hardcoded fallback is worse than a crash here: if another Rindle project happens to hold that port, the read **succeeds** against the wrong database. The CLI and the daemons fence themselves against that with a project fingerprint, but a browser or an app-tier HTTP client sends no identity and cannot be fenced. To keep the old numbers instead, pin the block in `rindle.ncl`: ```text { portBase = 7600 } ``` `rindle render` prints the resolved URLs for whichever block you get. ## Performance: subscribing to too much Subscribe to **windows**, not whole tables — `orderBy` + `limit`, and raise the limit for "load more". IVM keeps the window (and any `countAs`) exact as rows enter and leave. Add SQL indices for the directions your joins and windows traverse — see [Performance](https://rindle.sh/docs/performance). ## Next steps - [Isomorphic mutators](https://rindle.sh/docs/mutators) — the write contract most of these rules protect. - [Supported query shapes](https://rindle.sh/docs/supported-queries-ts) — what the builder can and can't express. - [The API server](https://rindle.sh/docs/api-server) — validation, authority, and the two rejection shapes. - [Run the daemon](https://rindle.sh/docs/daemon) — ports, planes, and restart recovery. --- [View this page on Rindle](https://rindle.sh/docs/troubleshooting) --- # API & package map Find a component by responsibility, then open generated TypeScript signatures or Rust API documentation for this source revision. Use this reference map to find public APIs by package or responsibility. Open the [generated API reference](https://rindle.sh/reference/) for exported symbols, signatures, options, return types, and source links. The tables below connect those APIs to usage guides and supported compositions. For a first project, start with [Onboarding](https://rindle.sh/docs/getting-started). For a task such as pagination, authorization, or deployment, use [Guides](https://rindle.sh/docs/guides). ## Generated API reference The reference includes every TypeScript entry point declared by public package export maps. It also identifies the private Node wrapper and any unavailable native binding declarations. Search by symbol, package, option, or return type. The docs search also includes TypeScript symbols. - [TypeScript package and symbol index](https://rindle.sh/reference/) - [Core Rust engine: `rindle`](https://rindle.sh/reference/rust/rindle/index.html) - [Embedded database: `rindle-replica`](https://rindle.sh/reference/rust/rindle_replica/index.html) - [SQLite sources: `rindle-sqlite`](https://rindle.sh/reference/rust/rindle_sqlite/index.html) - [Machine-readable signatures](https://rindle.sh/reference/reference.json) - [Revision, manifest versions, distribution, and build checks](https://rindle.sh/reference/metadata.json) Rustdoc includes linked local dependencies, SQLite types, source, and its own search. The build uses the default features of these crates and unifies their dependency features. Optional feature APIs require a reference build that enables those features. Every reference build records its Git revision and a digest of its TypeScript source inputs. Local changes are marked explicitly, and source snapshots preserve the exact TypeScript input. Full builds also record a digest of Rust sources, manifests, build scripts, and the Cargo lockfile. Manifest versions describe that checkout; a development version is not proof of a published npm release. The Rust crates currently require repository dependencies (`publish = false`). Declaration generation and rustdoc checks do not replace runtime tests. The build metadata links to the CI run when one is available. ## Browser and TypeScript clients The [browser client chooser](https://rindle.sh/docs/browser-clients) explains the tradeoffs. These packages share query and view types, but they have different responsibilities. | Responsibility | Package | Entry points | | --- | --- | --- | | Schema, query builder, views, and backend interface | `@rindle/client` | `createSchema`, `newQueryBuilder`, `Store`, `Backend`, `ArrayView` | | Local engine over application-supplied rows | `@rindle/wasm` | [`createWasmStore`, `WasmBackend`](https://rindle.sh/docs/wasm-client) | | Flat server query results, without browser WASM | `@rindle/remote` | [`createRemoteStore`, `RemoteBackend`, `WsTransport`](https://rindle.sh/docs/backends) | | Local engine over normalized server rows | `@rindle/normalized` | [`createNormalizedStore`, `NormalizedBackend`](https://rindle.sh/docs/browser-clients) | | Transport for normalized row subscriptions | `@rindle/remote` | `createRemoteNormalizedSource`, `createRemoteOptimisticSource` | | Prediction and replay over a supplied optimistic source | `@rindle/optimistic` | `createOptimisticStore`, `OptimisticBackend` | | Standard API-server and daemon integration | `@rindle/optimistic` | [`createRindleClient`](https://rindle.sh/docs/client) | `@rindle/client` does not connect to a server by itself. The flat remote client needs a compatible flat-protocol server; the standard daemon emits normalized rows. The normalized and optimistic source constructors are composition APIs. They do not all supply the lease, reconnect, and mutation lifecycle of `createRindleClient`. ## Embedded engines and databases | Responsibility | Package or crate | Entry points and guide | | --- | --- | --- | | Native SQLite store in Node (repository build) | `@rindle/replica` | [`createReplicaStore`](https://rindle.sh/docs/backends#node-live-views) | | Rust engine | `rindle` | [`Graph`, `build_pipeline`, sources, and views](https://rindle.sh/docs/how-it-works) | | Rust SQLite source | `rindle-sqlite` | [`TableSource` and write-through helpers](https://rindle.sh/docs/quickstart) | | Embedded SQLite with SQL change capture and live queries | `rindle-replica` | [`Db`, `Cluster`, `QueryId`, `Update`](https://rindle.sh/docs/replica-and-views) | Start with `rindle-replica` when you want a database in your own Rust process. Start with the core graph when your application supplies row changes explicitly. Persistence, writes, delivery, and cleanup depend on that choice. The repository also contains `@rindle/server`, a private reference server for the flat remote protocol. Standard optimistic synced apps use the [API server and data tier](https://rindle.sh/docs/architecture). ## Query definitions | Responsibility | API | Guide | | --- | --- | --- | | Tables and relationships | `table`, `createSchema`, `defineRelationships`, `rel`, generated `schema.gen.ts` | [Schema and migrations](https://rindle.sh/docs/schema) | | Typed query shapes | Filters, relationships, ordering, limits, and aggregates | [TypeScript](https://rindle.sh/docs/supported-queries-ts), [Rust](https://rindle.sh/docs/supported-queries) | | Named queries and UI data requirements | `defineQuery`, `defineFragment`, `useRoot`, `useFragment` | [Queries and fragments](https://rindle.sh/docs/fragments) | | Individual engine changes | `SourceChange`, `CaughtChange` | [Change model](https://rindle.sh/docs/change-model), [Rust delta consumer](https://rindle.sh/docs/example-rust) | | Server results retained without subscribers | `pinnedQueries`, `assertPins`, `readQuery` | [Pinned queries](https://rindle.sh/docs/pinned-queries) | The TypeScript builder is shared across stores. Named queries add an identity for server resolution. An ordinary SQL statement uses the SQL interface and does not become a live query automatically. ## Synced applications | Responsibility | Package | Entry points and guide | | --- | --- | --- | | Browser sync and optimistic state | `@rindle/optimistic` | [`createRindleClient`](https://rindle.sh/docs/client) | | Shared write logic | `@rindle/client` | [`shared`, `defineMutators`, `isoTx`](https://rindle.sh/docs/mutators) | | Server authorization and execution | `@rindle/api-server` | [`createRindleApiServer`, `registerQueries`, `sharedApiMutators`](https://rindle.sh/docs/api-server) | | Component subscriptions | `@rindle/react` | [`RindleProvider`, `useRoot`, `useFragment`](https://rindle.sh/docs/fragments) | | TanStack Start integration | `@rindle/tanstack` | [`createRindleTanStack`](https://rindle.sh/docs/tanstack) | | Server rendering and hydration | `@rindle/client`, `@rindle/react` | [`createServerStore`, `RindleSSR`, and the SSR lifecycle](https://rindle.sh/docs/ssr) | | Development inspection | `@rindle/devtools`, `@rindle/react-devtools` | [Devtools](https://rindle.sh/docs/devtools) | The [manual quickstart](https://rindle.sh/docs/synced-app-quickstart) connects the required pieces. Framework adapters and devtools are optional. For lower-level package boundaries, see the [crate map](https://rindle.sh/docs/crates). ## SQL and operations | Responsibility | Package or tool | Guide | | --- | --- | --- | | SQL over HTTP and interactive transactions | `@rindle/sql-client`, `createSqlClient` | [Rindle SQL](https://rindle.sh/docs/sql-client) | | Local development and migrations | `@rindle/cli`, `rindle` | [CLI commands](https://rindle.sh/docs/rindle-cli) | | Live-query server process | `rindled` | [Daemon configuration](https://rindle.sh/docs/daemon) | | Advanced control-plane access | `@rindle/daemon-client` | [API server transports](https://rindle.sh/docs/api-server#talking-to-rindle) | | PostgreSQL change capture and writes | `rindle-pg-gateway`, `postgresBackend` | [PostgreSQL source (preview)](https://rindle.sh/docs/postgres-source) | | Hosting and recovery | Self-hosted deployments or Rindle Cloud | [Deployment](https://rindle.sh/docs/deploy), [Cloud setup](https://rindle.sh/docs/cloud-quickstart) | For a request that fails, read [Troubleshooting](https://rindle.sh/docs/troubleshooting). For machine-readable documentation, use the [coding-agent guide](https://rindle.sh/docs/for-agents). --- [View this page on Rindle](https://rindle.sh/docs/api) --- # TypeScript queries Look up supported TypeScript query shapes, examples, and current restrictions. This reference covers the maintained queries exposed by `@rindle/client`. The browser, native Node, and remote stores share this builder. Their data and write lifecycles differ; see [backends](https://rindle.sh/docs/backends). The [Rust builder](https://rindle.sh/docs/supported-queries) uses the same engine, but exposes additional aggregate methods. The current TypeScript builder supports counts. Rust also exposes `sum` and `avg`. These limits apply to maintained queries. [Rindle SQL](https://rindle.sh/docs/sql-client) runs ordinary SQL requests; its expression support does not define the query builder. ## How you express a query `store.query.
    ` builds a query against your schema. Each method returns a new builder. `materialize()` creates a live view, and `view.data` reads its current result. This complete example uses an in-memory browser store: ```ts import { boolean, createSchema, createWasmStore, gt, number, string, table, } from "@rindle/wasm"; const issue = table("issue").columns({ id: number(), title: string(), priority: number(), open: boolean(), createdAt: number(), status: string(), projectId: number(), }).primaryKey("id"); const comment = table("comment").columns({ id: number(), issueId: number(), body: string(), spam: boolean(), }).primaryKey("id"); const project = table("project").columns({ id: number(), name: string(), }).primaryKey("id"); const schema = createSchema({ tables: [issue, comment, project] }); const store = await createWasmStore(schema); const view = store.query.issue .where.open(true) .where.priority(gt(3)) .orderBy("createdAt", "desc") .limit(50) .materialize(); const unsubscribe = view.subscribe((rows) => console.log(rows)); await store.write((tx) => tx.add("issue", { id: 1, title: "Ship it", priority: 7, open: true, createdAt: 1700000000, status: "todo", projectId: 1, })); unsubscribe(); view.destroy(); ``` Use the [browser setup](https://rindle.sh/docs/wasm-client#install) to install the package and load WebAssembly. Later examples reuse these tables and the store. Destroy each view when its consumer no longer needs it. A view listener receives the complete current result immediately, then when it changes. These are result arrays, not Rust `Hydrated` or `Changed` events. The store applies the deltas for you. Local writes and optimistic predictions can change a view before any server commit exists. `where` accepts either a condition or a typed field accessor: ```ts import { or, eq, gt } from "@rindle/client"; store.query.issue.where(or(issue.priority(gt(8)), issue.open(eq(true)))); store.query.issue.where.open(true); store.query.issue.whereOpen(true); ``` `.ast()` returns the query's wire representation. In a synced app, a bare `store.query` reads local rows only. A [named query](https://rindle.sh/docs/client) requests an authorized server subscription. The builder does not acquire remote rows merely because it references a table. ## Correlated relationships and EXISTS Relationships are correlated subqueries. You give the correlation as `{ parent: [...], child: [...] }` — the parent columns on the left, the child columns they match on the right. ```ts import { exists, notExists } from "@rindle/client"; // issues, each carrying its comments (a materialized relationship) const withComments = store.query.issue .sub("comments", comment, { parent: ["id"], child: ["issueId"] }) .materialize(); // rows: ( …Issue & { comments: Comment[] } )[] // only issues that have at least one comment (an EXISTS filter) const commented = store.query.issue .where(exists(comment, { parent: ["id"], child: ["issueId"] })) .materialize(); // the negation const uncommented = store.query.issue .where(notExists(comment, { parent: ["id"], child: ["issueId"] })) .materialize(); ``` `sub` takes an optional final builder to shape the child (`(c) => c.orderBy("id", "asc")`), and `exists` / `notExists` take an optional builder to filter the gate (`(c) => c.where.spam(false)`). If you don't want to restate the same correlation at every call site, declare each join once with `defineRelationships` and pass the named relationship to `sub` / `countAs` / `exists` instead of the `{ parent, child }` object. It lowers to the exact same correlated subquery: ```ts import { defineRelationships, rel } from "@rindle/client"; const rels = defineRelationships({ issueComments: rel(issue, comment, { id: "issueId" }), // issue.id → comment.issueId }); store.query.issue.sub("comments", rels.issueComments).materialize(); store.query.issue.where(exists(rels.issueComments)).materialize(); ``` ## Aggregates: a live `count` `countAs` attaches a **correlated child count** to each parent row as a scalar, maintained incrementally. Adding or removing a child increments or decrements the count without re-scanning. An empty child reads `0`. The alias resolves to a `number`, not an array: ```ts const view = store.query.issue .countAs("commentCount", comment, { parent: ["id"], child: ["issueId"] }) .materialize(); // rows: ( …Issue & { commentCount: number } )[] ``` `countAs` preserves the parent result shape. The [top-level `count`](#aggregates) method below instead returns aggregate rows. The current TypeScript builder does not expose `sum`, `avg`, `min`, or `max`; see the [Rust reference](https://rindle.sh/docs/supported-queries) for Rust's additional aggregate methods. ## Scalar subqueries: fold a unique lookup at build time When an `exists` child binds a **statically-unique key** (a primary key or a unique index, fully pinned to constants), pass `{ scalar: true }`. The resolver reads that one row **once at build time**, inlines its correlation value as a literal, and deletes the join entirely. The parent pipeline never subscribes to the child table. ```ts import { exists } from "@rindle/client"; // only the project that owns issue #7 — resolved once, then a plain literal filter store.query.project.where( exists(issue, { parent: ["id"], child: ["projectId"] }, (i) => i.where.id(7), { scalar: true }), ); ``` The trade-off is **snapshot semantics**: the inlined value is frozen for the pipeline's lifetime, so changes to the child after build do not propagate. Leave `scalar` off (the default) for an ordinary live `exists` join. ## Aggregates `countAs` attaches a live count to each parent row. A query can also *be* the aggregate: `count()` reshapes the result to count rows instead of materializing them. `groupBy` keys it, and `having` filters the post-aggregation rows — all maintained incrementally, like any other shape: ```ts import { gt } from "@rindle/client"; // one { count } row, maintained as rows enter and leave the filter store.query.issue.where.open(true).count().materialize(); // one { status, count } row per distinct status, HAVING count > 3. // The `having` proxy addresses the aggregate's OUTPUT columns — the groupBy // columns and the synthetic `count` (which lives on no base table). store.query.issue .groupBy("status") .count() .having((h) => h.count(gt(3))) .materialize(); // filter the PARENT by a child count — issues with more than 10 comments. // This `having(alias, op, n)` overload takes a `countAs` alias already on the query. store.query.issue .countAs("commentCount", comment, { parent: ["id"], child: ["issueId"] }) .having("commentCount", ">", 10) .materialize(); ``` `having` filters *above* the aggregation. `where` filters base rows *below* it. The parent-by-child-count overload accepts **high-pass** predicates only in v1 (see the matrix and rejections below). The dropped parent's visible `commentCount` is untouched — a survivor still shows its real count. ## Supported shapes **fetch** means initial hydration, **push** means incremental maintenance, and **view** means materialized result support. ✅ is supported, ⚠️ has the stated restriction, and ❌ is unavailable through this builder. | Query shape | fetch | push | view | Notes | |---|:---:|:---:|:---:|---| | Simple `where` (`=`,`!=`,`<`,`>`,`<=`,`>=`) | ✅ | ✅ | ✅ | `.where.field(v)` / `eq` `ne` `lt` `gt` `le` `ge` | | `is` / `isNot` (null-aware equality) | ✅ | ✅ | ✅ | `is(null)` matches null; ordinary equality with null does not | | `like` / `ilike` / `notIlike`, incl. `\%`/`\_`/`\\` escapes | ✅ | ✅ | ✅ | memory matcher agrees with SQLite | | `and` / `or` of leaf conditions | ✅ | ✅ | ✅ | `and(...)` / `or(...)` | | `inList` / `notInList` over a literal list | ✅ | ✅ | ✅ | `.where.field(inList([...]))` | | Sibling relationships (multiple `sub` on a row) | ✅ | ✅ | ✅ | | | Nested relationships (`sub` with its own `sub`) | ✅ | ✅ | ✅ | nest inside the child builder | | `start` paging bound | ✅ | ✅ | ✅ | `.start(cursor, { exclusive })` | | `limit` (ordered take / exists cap) | ✅ | ✅ | ✅ | `.limit(n)` | | `exists` (correlated EXISTS) | ✅ | ✅ | ✅ | `where(exists(child, corr))`; the engine picks the cheaper drive side (parent- or child-driven) internally | | `notExists` (NOT EXISTS) | ✅ | ✅ | ✅ | `where(notExists(child, corr))` | | Top-level `or` fan of EXISTS conditions | ✅ | ✅¹ | ✅ | | | Nested `or`/`and` mix of EXISTS conditions | ✅ | ✅¹ | ✅ | including AND-within-AND | | Multi-EXISTS under top-level `and`/`or` | ✅ | ✅ | ✅ | slots uniquified to distinct query-local ids | | Deepest-nested child push | ✅ | ✅ | ✅ | surfaces as a re-projected child subtree | | Self-join (reentrant fetch-during-push) | ✅ | ✅ | ✅ | | | Many-to-many through a junction table | ✅ | ✅ | ✅ | nest `sub` through the junction; junction rows materialize uncollapsed (no hidden-edge magic) | | Top-level `.one()` (singular root) | ✅ | ✅ | ✅ | caps the query to limit 1; the view's `.data` is `row \| null` | | Relationship-level `.one()` (a singular `sub`) | ⚠️ | ⚠️ | ⚠️ | view layer implemented + unit-tested, not yet reachable via a query (builds plural today) | | Aggregate: `countAs` of a correlated child | ✅ | ✅ | ✅ | a scalar count per parent row; empty children read `0` | | `sum` / `avg` / `min` / `max` | ❌ | ❌ | ❌ | not exposed by the current TypeScript builder | | Top-level `count()` (global aggregate) | ✅ | ✅ | ✅ | reshapes the result to one `{ count }` row instead of materializing rows | | `groupBy` + `count()` (grouped aggregate) | ✅ | ✅ | ✅ | one `{ …group, count }` row per distinct value-tuple, keyed and sorted by the group columns | | `having((h) => …)` (filter post-aggregation rows) | ✅ | ✅ | ✅ | the proxy addresses the `groupBy` columns and the synthetic `count` | | `having(alias, op, n)` (filter a parent by a child count) | ⚠️ | ⚠️ | ⚠️ | gates a parent by a `countAs` alias, maintained incrementally; **v1: high-pass predicates only** — see rejections below | | Scalar subquery (`exists` with `{ scalar: true }`) | ✅ | —² | ✅ | a build-time **snapshot**: a unique-key match is folded to a literal and the join is removed | | Projection / column pruning (`select`) | ✅ | ✅ | ✅³ | `.select("id", "title")` shapes the returned fields; the engine also needs keys and columns used by filters, ordering, and correlations | | Legacy ZQL `static` parameter nodes | ❌ | ❌ | ❌ | distinct from supported named-query arguments and server query-family sharing | ¹ **`exists` under a union fan, on push — a deliberate divergence from upstream.** For one internal lowering of an `exists` under a top-level `or` fan, Zero's JS engine (which Rindle ports) emits a push result that *violates* the IVM contract. Rindle upholds *view-after-push == fresh-query*, pinned by a dedicated consistency test (`union_fan_consistency`). So the ✅ is real, but on this one shape Rindle intentionally does **not** match upstream ZQL output. ² **A scalar subquery does not push.** That is the point: it is resolved **once, at build time**, inlined to a literal, and the join is deleted. The parent pipeline never subscribes to the child table, and later changes to it do not propagate. Opt in per-condition (`{ scalar: true }`). Leave it off for a live join. ³ **Projection also has internal requirements.** The TypeScript result type and projected output follow the selected columns. The engine also retains columns needed to resolve filters, order, keys, and relationships. A narrow result does not guarantee a narrow SQLite disk read. `like` is case-sensitive. `ilike` folds ASCII case only. The core matcher's `_` wildcard matches a byte, so non-ASCII single-character matching has a known limit. Named query arguments are supported: the definition validates them and builds a concrete AST. Server query families can share eligible queries that differ in root equality values. These features do not use legacy `static` AST nodes. ## Relationship slots are query-local When two or more EXISTS conditions sit under a top-level `and` / `or`, the engine uniquifies their internal slots to distinct query-local ids. You don't name them (only `sub` and `countAs` take an explicit alias). The slot layout is **derived from the query AST**, not from any engine-level schema. That is why a `defineRelationships` value is pure convenience over the same inline correlation, and a production-shaped schema needs no synthesized gate slots. The slot order is materialized relationships (`sub` / `countAs`) first, then the `exists` gates in `where`-tree pre-order. That is the one tree shared by the dataflow joins, the gates, and the view materialization, so their relationship ids agree by construction. ## Build-time rejections These are genuine limitations. Two of them you hit before the query ever builds. A typed schema catches unknown table and column names during TypeScript checking. Untrusted or hand-written ASTs still need runtime validation. Unsupported shapes fail during materialization or server query resolution: - **A root aggregate combined with row-shaping** — pairing `count()` with `select` / `sub` / `countAs` / `orderBy` / `limit` / `one` is rejected: a `count()` query's result *is* the aggregate output (`groupBy` columns + `count`), not rows. Paging and correlated subqueries in a root aggregate's `where` or `having` are also unsupported. - **A parent-by-child-count predicate that is true at zero** — for example, `= 0`, `>= 0`, or `!= 1`. A childless parent has no aggregate group, so these filters are rejected. Use a single numeric comparison that is false at zero, such as `> 0`, `>= 2`, `= 2`, or `!= 0`. - **An `exists` subquery carrying a paging bound (`start`) or a nested relationship (`sub`)** → `BuildError::Unsupported`. - **A bare top-level `exists` whose implied slot collides with a `sub` of the same name** → `BuildError::Unsupported` (one relationship per slot). Two `exists` under a top-level `and` / `or` are uniquified to distinct slots and never collide. ## A note on value types Unlike the Rust delta stream — where cells arrive as an `OwnedValue` enum you match on — the TypeScript view hands you **plain typed JavaScript values**, shaped by your schema. A `number()` column reads back as `number`, `string()` as `string`, `boolean()` as `boolean`, and an optional column can be `null`. Missing projected data differs from SQL `NULL`; it does not become a complete row until the required columns arrive. A `json()` column reads as the JSON value typed by `T`: ```ts import { table, string, number, boolean, json } from "@rindle/client"; const issue = table("issue") .columns({ id: number(), title: string(), priority: number(), open: boolean(), tags: json(), // read back as string[] }) .primaryKey("id"); // view rows are fully typed — no enum matching, no manual coercion: // { id: number; title: string; priority: number; open: boolean; tags: string[] } ``` The comparator that orders and dedupes rows is keyed on the column type, so the sort you get in TypeScript is byte-for-byte the sort the Rust engine produces. For a standalone store, define the schema in TypeScript. For a database-backed application, [generate it from SQL](https://rindle.sh/docs/schema). Put application refinements in your own module; do not hand-edit the generated schema. SQL is the source of truth for persistent columns, types, and keys. ## Next steps - [Reactive queries in the browser](https://rindle.sh/docs/wasm-client) — build and materialize a query end to end on the in-process wasm engine. - [The browser client](https://rindle.sh/docs/client) — the same builder in a synced app: `defineQuery`, optimistic writes, live views. - [Compose the UI with fragments](https://rindle.sh/docs/fragments) — split a query across the component tree with the same `select` / `sub` / `countAs` surface. - [The change model](https://rindle.sh/docs/change-model) — the delta vocabulary the view folds for you. - [Synced-app quickstart](https://rindle.sh/docs/synced-app-quickstart) — these shapes in a real React app. --- [View this page on Rindle](https://rindle.sh/docs/supported-queries-ts) --- # Optimistic browser client Create a synced browser client, subscribe to named queries, and manage optimistic writes and client resources. `createRindleClient` is Rindle's integrated **optimistic browser client**. It connects a local wasm engine to your API server and data tier. It manages named query subscriptions, predicts writes, and reconciles them with server results. This is one browser option. For local data, remote results without wasm, or normalized sync without prediction, [choose a browser client](https://rindle.sh/docs/browser-clients). Using React is a separate choice; this client also works with ordinary DOM code and other UI frameworks. ## Prerequisites You need a running Rindle data tier and an [API server](https://rindle.sh/docs/api-server) with your schema, named queries, and mutators. [`create-rindle`](https://rindle.sh/docs/create-rindle) generates this setup. [Manual synced-app setup](https://rindle.sh/docs/synced-app-quickstart) shows each piece. This page focuses on the browser. Its examples use the manual quickstart's SQL-generated `issue` table, including these columns: | Column | Type | | --- | --- | | `id`, `title`, `status` | `string` | | `createdAt`, `updatedAt` | `number` | Keep the generated schema authoritative. The examples import it from `shared/schema.gen.ts`; they do not redefine the server's tables in TypeScript. Install the browser packages and the validator used below: ```sh pnpm add @rindle/client @rindle/optimistic zod ``` Your browser build must load WebAssembly assets. `createRindleClient` initializes wasm for you. For a custom asset URL, call `initWasm` first as described in the [wasm guide](https://rindle.sh/docs/wasm-client#initialization-and-persistence). ## Define the shared query and writes A **named query** supplies the server identity for a subscription. A **mutator** is a named write that the browser predicts and the server executes with authority. Both belong in shared modules that your browser and API server can import. For this example, put the following in `shared/client-example.ts`: ```ts import { defineMutators, defineQuery, newQueryBuilder } from "@rindle/client"; import type { MutationGen } from "@rindle/client"; import type { ClientRegistry } from "@rindle/optimistic"; import { z } from "zod"; import { schema } from "./schema.gen.ts"; export { schema }; const q = newQueryBuilder(schema); export const issuesPageQuery = defineQuery( "issuesPage", z.object({ limit: z.number().int().min(1).max(100) }).parse, ({ limit }) => q.issue.orderBy("createdAt", "desc").limit(limit), ); const { shared } = defineMutators(schema); export const mutators = { setStatus: shared( z.object({ id: z.string(), status: z.string(), updatedAt: z.number() }), function* (tx, args): MutationGen { yield tx.update("issue", args); }, ), setTitle: shared( z.object({ id: z.string(), title: z.string(), updatedAt: z.number() }), function* (tx, args): MutationGen { yield tx.update("issue", args); }, ), } satisfies ClientRegistry; ``` Register this query and these mutators with your API server. Defining them in the browser alone does not authorize or register them on the server. Add application access rules there; the sample bodies only demonstrate updates. A shared mutator is a generator that yields logical operations. Its body runs again during rebase, so it must be deterministic. Supply clocks, random IDs, and other external values as arguments. An update to a missing row is a no-op. See [Isomorphic mutators](https://rindle.sh/docs/mutators) for reads, permissions, and the full operation vocabulary. ## Create one client for the browser session Put this in `src/rindle-client.ts`. This example assumes your API server accepts a bearer token and derives the authenticated user from it: ```ts import { createRindleClient } from "@rindle/optimistic"; import { mutators, schema } from "../shared/client-example.ts"; export interface Session { userID: string; accessToken: string; } export async function openClient(getSession: () => Session) { return createRindleClient({ schema, mutators, user: () => getSession().userID, api: { url: "", headers: () => ({ authorization: `Bearer ${getSession().accessToken}`, }), }, onRejected: (envelope, reason) => { console.error(`Mutation ${envelope.name} rejected: ${reason}`); }, onMutationError: (error, attempt) => { console.error(`Mutation request failed on attempt ${attempt}`, error); }, }); } export type AppClient = Awaited>; ``` Call `openClient` from browser startup with your application's session reader. Reuse the returned client across screens. If your application uses same-origin session cookies, adapt the header configuration to that authentication setup. The local `user` option affects predictions; it does not authenticate a request. The server supplies its own verified `ctx.user`. With `api.url: ""`, the client calls the same-origin routes `/api/rindle/query` and `/api/rindle/mutate`. Set another base URL or `api.routes` when your server exposes them elsewhere. `api.headers` can be an object or a function, including an asynchronous function. It runs for each request. Normally, omit `daemon`. The first query lease returns its public WebSocket endpoint and affinity ticket. The client opens that connection and handles subsequent recovery and supported endpoint changes. Advanced integrations can supply `daemon: { wsUrl }` or a fixed `{ transport }`. The result includes `store`, `backend`, `mutate`, `ensure`, `flushFolds`, `clientID`, and `close`. Creating it initializes the client; it does not wait for all of your application queries to load. For SSR, create the browser client only after entering the browser. Use the separate [server-rendering setup](https://rindle.sh/docs/ssr) for server reads and hydration. ## Reads: live views A named query opens a server subscription. Its local view can appear before the first server answer, using rows already present in the browser. ### Without React The following DOM integration owns one client and one query. It accepts an existing element and your session reader, then returns a cleanup function: ```ts import { issuesPageQuery } from "../shared/client-example.ts"; import { openClient } from "./rindle-client.ts"; import type { Session } from "./rindle-client.ts"; export async function mountIssues(element: HTMLElement, getSession: () => Session) { const app = await openClient(getSession); const view = app.store.materialize(issuesPageQuery({ limit: 50 })); const unsubscribe = view.subscribe((rows) => { const list = document.createElement("ul"); for (const row of rows) { const item = document.createElement("li"); const button = document.createElement("button"); button.textContent = `${row.title} — ${row.status}`; button.onclick = () => { app.mutate.setStatus({ id: row.id, status: "done", updatedAt: Date.now() }); }; item.append(button); list.append(item); } const status = view.resultType === "unknown" ? "Loading…" : "Issues"; element.replaceChildren(document.createTextNode(status), list); }); return () => { unsubscribe(); view.destroy(); app.close(); element.replaceChildren(); }; } ``` `subscribe` fires immediately and on data or readiness changes. `view.data` is the current result. Keep the view alive while its owner needs that result. Removing the listener alone does not release the query. ### With React Install `@rindle/react` in your React application. Pass the existing client store to `Rindle`; the hooks manage query views and subscriptions: ```tsx import { Rindle, useQuery, useQueryStatus } from "@rindle/react"; import { issuesPageQuery } from "../shared/client-example.ts"; import type { AppClient } from "./rindle-client.ts"; export function IssueApp({ app }: { app: AppClient }) { return ; } function IssueList() { const query = issuesPageQuery({ limit: 50 }); const rows = useQuery(query); const status = useQueryStatus(query); return ( <> {status === "unknown" &&

    Loading…

    }
      {rows.map((row) =>
    • {row.title}
    • )}
    ); } ``` Mount `IssueApp` through your existing React root. Dispose that root before closing its client. Unchanged result rows retain object identity, which helps memoized components avoid work. Separate queries are useful for independent screens or widgets. When a parent and its descendants share a related data tree, [fragments](https://rindle.sh/docs/fragments) can compose their requirements into one named root query. React retains an unused query for two seconds by default. This can reuse rows and subscriptions during short navigation gaps. For transient queries such as search prefixes, pass `{ releaseDelayMs: 0 }` to `useQuery` and any corresponding `useQueryStatus` call. A shared reader's unexpired retention window can still keep that query alive. See [Search and typeahead](https://rindle.sh/docs/typeahead). ## Loading and pending writes For a named query, `view.resultType` starts as `unknown` and becomes `complete` after the server answers. An empty result can be complete. An optimistic write does not turn a complete result back into a loading result. `error` is reserved; the current client does not use it as a general query-error channel. Read `view.resultType` in the view's subscription, as above, or use `useQueryStatus`. The `Store` also exposes an additive `subscribeResultType` observer for integrations. Do not install `backend.onResultType` yourself: the store already owns that callback. The backend separately exposes `pendingTables()` and query-level pending hooks for custom integrations. These describe unconfirmed writes touching tables; they are not a query's initial-loading state or proof that a particular row is pending. See [Devtools](https://rindle.sh/docs/devtools) for inspecting individual mutations. For navigation that must wait for an authoritative query result, use `await app.ensure(namedQuery)`. See [Preloads](https://rindle.sh/docs/preloads) for readiness and retention options. ## Writes: optimistic, rebased `app.mutate.setStatus(args)` runs the shared body against local rows synchronously. Affected views update before the call returns. The return value is a mutation ID, not an acknowledgment promise. The client queues the mutation's name and arguments. Your API server runs the registered body in an authoritative transaction. As confirmed changes arrive, the browser removes settled predictions and reapplies pending bodies to the confirmed data. A prediction can change during this rebase if its inputs differ from the server's data. This behavior also supports read-dependent writes. A mutator that reads a row and increments its value reads again during rebase. A missing local row can produce no visible prediction even if the server later performs the write. Raw `store.write()` is not the write API for synced tables on this client. Use named mutators for those tables and `writeLocal` for explicitly local tables. ## Folded writes: high-frequency drags For a setter that receives frequent replacement values, `.folded()` updates the local prediction on every call while delaying the server write. Calls with the same mutator name and key replace one pending set of arguments. Using `setTitle` from the shared module above: ```ts import type { AppClient } from "./rindle-client.ts"; export function saveTitle(app: AppClient, id: string, title: string) { return app.mutate.setTitle.folded( { key: id, debounceMs: 120, maxWaitMs: 1000 }, { id, title, updatedAt: Date.now() }, ); } ``` The write flushes after 120 milliseconds without another same-key call. During sustained input, a call at least one second into the window also flushes it. The threshold is checked on calls, not by a separate deadline timer. A long interaction can therefore produce several server writes. The returned handle has `flush()` and a `mid` promise. `flush()` ends the current fold window. `mid` resolves when the client assigns the flushed write its wire ID; it does **not** mean the server accepted that write. A folded mutator must be **absorbing**: applying only the last arguments must produce the same state as applying all of them. Setters can meet this contract; increments do not. The folded path rejects mutators that read state. See [Folded mutations](https://rindle.sh/docs/folded-mutations) for overlapping writes and flush rules. `app.flushFolds()` flushes all open folds. The client also attempts this on page exit. Neither mechanism guarantees delivery before a page closes or crashes. ## Rejections and network failures A final rejection removes the refused prediction during reconciliation. `onRejected(envelope, reason)` supplies the reason for your UI. An authoritative transaction failure does not commit the rejected application changes; mutation progress can still advance so the client can settle that mutation. A failed HTTP mutation request is different. `onMutationError(error, attempt)` reports an attempt without a final verdict. The client retries with backoff, and later queued mutations wait behind that batch. Pending predictions remain while delivery is unresolved. The queue lives in memory. It is not a durable offline queue, and a reload can lose pending writes. See [Rejected writes](https://rindle.sh/docs/rejected-writes) for the application-facing error pattern. ## Local-only tables: drafts, selections, prefs To keep browser-owned UI data alongside synced rows, extend the generated schema with tables marked `local: true`: ```ts // shared/schema.local.ts import { extendSchema, string, table } from "@rindle/client"; import { schema as generatedSchema } from "./schema.gen.ts"; const draft = table("draft", { local: true }) .columns({ id: string(), body: string() }) .primaryKey("id"); export const clientSchema = extendSchema(generatedSchema, { tables: [draft] }); ``` Pass `clientSchema` as the browser client's schema. Keep the generated synced schema in the API server and named-query registry. The client's `store.writeLocal` can write these local tables; ordinary mutators cannot read or write them. Local queries can combine local tables with available synced rows. Rebase does not rewind local tables. They are not sent to the server, so an unrelated server confirmation or rejection does not overwrite a draft. By default, local rows also live only in memory. The `persistLocal` client option restores eligible local tables from IndexedDB before construction resolves. It can coordinate those local tables across tabs. It does not persist synced rows or pending mutations. See [Local-only tables](https://rindle.sh/docs/local-only-tables) and [Persisting local tables](https://rindle.sh/docs/persisting-local-tables) for write examples, user identity, session-only tables, and logout cleanup. ## Local query resolution A bare `app.store.query.issue.where.id(id).one().materialize()` opens a local view without a server subscription. It reads rows currently retained in the browser. It can be useful when another named query already provides the relevant data. It cannot prove server absence or a complete table-wide result. Releasing named queries can remove rows that no remaining subscription retains. An independent local view does not keep that remote data subscribed. Use a named query when you need the server to supply and maintain its result. ## Close the client Unmount framework consumers and destroy manually created views before calling `app.close()`. The method releases the client's connections, query preloads, timers, and local-persistence attachment. Do not reuse a closed client. `close()` attempts to flush open folds. It does not wait for server confirmation and is not a save operation. Recreate the client when switching its authenticated user, and follow the persistence guide's explicit logout cleanup if local data must be deleted. The default `clientID` separates mutation sequences by origin, tab, and client instance. Keep it unless your integration deliberately manages that identity. Persisting an identity does not persist its queued writes. ## Next steps - [Isomorphic mutators](https://rindle.sh/docs/mutators) — define deterministic reads and writes with server authority. - [Fragments](https://rindle.sh/docs/fragments) — compose a related UI tree into one named query. - [Preloads](https://rindle.sh/docs/preloads) — prepare data before navigation. - [Server rendering](https://rindle.sh/docs/ssr) — seed a page and hand off to the browser client. - [Devtools](https://rindle.sh/docs/devtools) — inspect queries, predictions, and confirmed changes. - [API server](https://rindle.sh/docs/api-server) — register and authorize the shared contract. --- [View this page on Rindle](https://rindle.sh/docs/client) --- # The API server Resolve authorized named queries, run authoritative mutators, and serve one-shot reads from the Rindle data tier. `@rindle/api-server` connects your application rules to the Rindle data tier. It resolves named queries, authorizes requests, runs authoritative mutators, and serves one-shot reads. It works in a persistent process or a serverless handler. Your HTTP adapter authenticates the caller and supplies a verified user context. The package does not verify sessions or JWTs for you. The data tier stores rows, maintains queries, and serves the browser's authorized WebSocket subscriptions. Use this package for a synced app or authorized server read models. For ordinary SQL without that application protocol, use [Rindle SQL](https://rindle.sh/docs/sql-client). ## The server This example uses the schema, `issuesPageQuery`, and shared mutators from the [browser client guide](https://rindle.sh/docs/client#define-the-shared-query-and-writes). Its issue list is shared by all authenticated users. Add row-level filters when your app has private rows. Define the server's user type in `server/auth.ts`: ```ts export type User = string | undefined; export function requireUser(user: User): string { if (!user) throw new Error("authentication required"); return user; } ``` Put the server configuration in `server/api.ts`: ```ts import { createRindleApiServer, registerQueries, sharedApiMutators, } from "@rindle/api-server"; import { issuesPageQuery, mutators, schema } from "../shared/client-example.ts"; import { requireUser } from "./auth.ts"; import type { User } from "./auth.ts"; export const api = createRindleApiServer({ rindle: {}, schema, queries: registerQueries([issuesPageQuery]), mutators: sharedApiMutators(mutators, ({ user }) => ({ user: requireUser(user) })), authorizeQuery: ({ user }) => Boolean(user), authorizeMutation: ({ user }) => Boolean(user), }); ``` `rindle: {}` reads `RINDLE_URL` and `RINDLE_DATABASE_TOKEN` from the server environment. `rindle dev` supplies them locally. You can pass `rindle: { url, token }` explicitly instead. Keep the token on the server. The unified connection creates both the SQL mutation transport and the daemon query-control client. A standalone deployment sends both to one daemon. A fleet edge routes writes to the master and query operations to a follower. `schema` is required for logical mutator operations such as `tx.update` and `tx.row`. Query-only servers and raw-SQL mutators can omit it. The query registry still needs a schema to construct its queries. The API instance owns clients that it creates. Call `api.close()` at application shutdown. Injected clients keep their caller-owned lifecycle. ## Named queries → ASTs `registerQueries` accepts shared `defineQuery` values. It validates each request's arguments and builds the authoritative query AST. The browser sends the name and arguments; it does not supply an AST for the server to trust. A query definition need not live next to a React component. Keep it in a shared module with no browser-only imports. Both the browser and server must register compatible versions of its name, arguments, and result shape. ### Queries scoped to the current user A context-scoped query accepts its context separately from its arguments. For the quickstart's `issue.ownerId` column, define: ```ts // shared/my-issues.ts import { defineQuery, newQueryBuilder } from "@rindle/client"; import { z } from "zod"; import { schema } from "./schema.gen.ts"; const q = newQueryBuilder(schema); export const myIssuesQuery = defineQuery( "myIssues", z.object({ limit: z.number().int().min(1).max(100) }).parse, ({ limit }, ctx: { user: string | undefined }) => q.issue.where.ownerId(ctx.user ?? "").orderBy("createdAt", "desc").limit(limit), ); ``` Add `myIssuesQuery` to `registerQueries([...])`. The server supplies its `ApiContext` (`{ user, request }`) as the query's context. The browser calls it as `myIssuesQuery({ limit: 20 }, { user: sessionUserID })` to construct its local query. Only `{ name, args }` crosses the query wire; the server derives its user from authentication again. Keep the authorization gate so an absent user cannot read rows through the example's empty-string fallback. ### Additional server filters When the server must add a filter, replace that name's resolver with a `defineApiQueries` entry. This is an alternative `queries` map for `server/api.ts`: ```ts import { defineApiQueries, registerQueries } from "@rindle/api-server"; import type { ApiQueries } from "@rindle/api-server"; import { issuesPageQuery } from "../shared/client-example.ts"; import { requireUser } from "./auth.ts"; import type { User } from "./auth.ts"; export const privateQueries = defineApiQueries>({ ...registerQueries([issuesPageQuery]), issuesPage: (ctx, args) => issuesPageQuery.resolve(args).where.ownerId(requireUser(ctx.user)), }); ``` Pass `privateQueries` as the API server's `queries` option. The later object entry replaces the shared resolver under the same name. `resolve(args)` runs its validator before the additional filter is applied. Encode data visibility in the authoritative query. A routing hint or affinity ticket is not a row-level authorization rule. The daemon can share a materialization when the canonical query and visibility scope match. `subject` and `routingKey` also affect placement and one-shot read reuse; see [Server rendering](https://rindle.sh/docs/ssr). The daemon can group eligible queries that differ in a root equality value into a parameterized query family. That optimization preserves each subscriber's result; it does not replace authorization. The daemon's `queryFamilies` option controls it. ## Driving the shared mutators `sharedApiMutators` turns the browser's shared registry into authoritative server handlers. It parses untrusted arguments through each mutator's `.args`, injects the server's authenticated `ctx.user`, and drives the generator's logical operations in one transaction. The browser predicts against local rows. The server runs against authoritative rows, so a read-dependent body can produce a different result. Rebase reconciles that difference. See [Isomorphic mutators](https://rindle.sh/docs/mutators) for the shared contract. On the standard SQL mutation backend, pure writes use one request. A mutator's first read opens an interactive mutation transaction. Its earlier writes, reads, later writes, and mutation watermark commit together. The protocol deduplicates replayed mutation IDs and rejects gaps. ### Server-only authority Override a mutator by name when the server must apply an additional rule. For example, this alternative registry adds a title rule to the shared body: ```ts import { runSharedMutation, sharedApiMutators } from "@rindle/api-server"; import type { ApiMutators } from "@rindle/api-server"; import { mutators } from "../shared/client-example.ts"; import { requireUser } from "./auth.ts"; import type { User } from "./auth.ts"; export const guardedMutators: ApiMutators = { ...sharedApiMutators(mutators, ({ user }) => ({ user: requireUser(user) })), setTitle: (tx, raw, ctx) => { const args = mutators.setTitle.args.parse(raw); if (/\bspam\b/i.test(args.title)) throw new Error("title is not allowed"); return runSharedMutation(mutators.setTitle, args, { user: requireUser(ctx.user) }, tx); }, }; ``` Pass `guardedMutators` as the API server's `mutators` option. `ServerMutationTx` also provides transaction-bound raw SQL through `tx.sql.execute`, `tx.sql.batch`, and `tx.sql.query`. Use that surface for server-only relational work. Raw reads see the transaction's earlier writes. These methods are absent from the shared browser transaction. A policy exception rejects the mutation and rolls back its application changes. The authority still advances its mutation watermark so the browser can remove the prediction. An accepted no-op also settles the prediction, but it does not produce `onRejected`. Infrastructure failures remain retryable failures; they do not become business rejections merely because a request failed. ### Work outside the mutation transaction A `scoped` mutator can do work before or after its one authoritative transaction. `scope.transact` opens that transaction. Each `scope.sql` call outside it commits independently. This example assumes tables `import_attempt(key TEXT PRIMARY KEY)` and `import_job(key TEXT PRIMARY KEY, status TEXT NOT NULL)` already exist: ```ts import { scoped } from "@rindle/api-server"; import { z } from "zod"; const importArgs = z.object({ key: z.string() }); export const finishImport = scoped(async (scope, raw: unknown) => { const { key } = importArgs.parse(raw); await scope.sql.execute( "insert into import_attempt (key) values (?) on conflict do nothing", [key], ); await scope.transact((tx) => tx.sql.execute("update import_job set status = 'complete' where key = ?", [key]), ); }); ``` An outside write can remain visible when the mutation transaction later fails. Outside work can run again on envelope replay, even if the authoritative transaction is absorbed. Give it an explicit idempotency rule. The example's unique attempt key makes its insert safe to repeat. A clean return from `scope.transact` follows the authoritative commit. A later failure cannot undo that commit. `onScopeError` reports such failures; without a handler, the API server logs them. For external effects that must survive a process crash, use your application's durable job or outbox mechanism. See [Streaming LLM responses](https://rindle.sh/docs/llm-streams) for a scoped workflow. ## Bring your own HTTP The package provides `handleQueryJson`, `handleReadJson`, and `handleMutateJson`. It does not start an HTTP listener. Its default route names are: | Route | Handler | Result | | --- | --- | --- | | `/api/rindle/query` | `handleQueryJson` | A query lease and connection metadata | | `/api/rindle/read` | `handleReadJson` | `{ rows, cvMin, queryKey }` | | `/api/rindle/mutate` | `handleMutateJson` | A mutation verdict or list of verdicts | The following Web-standard adapter takes your authentication function as an explicit dependency. Mount its returned handler in a framework or server that uses `Request` and `Response`: ```ts import { RindleApiError } from "@rindle/api-server"; import { api } from "./api.ts"; import type { User } from "./auth.ts"; export function createHandler(authenticate: (request: Request) => Promise) { return async (request: Request): Promise => { const path = new URL(request.url).pathname; if (![api.routes.query, api.routes.read, api.routes.mutate].includes(path)) { return new Response("Not found", { status: 404 }); } if (request.method !== "POST") { return new Response("Method not allowed", { status: 405, headers: { allow: "POST" } }); } let body: unknown; try { body = await request.json(); } catch { return Response.json({ error: "Invalid JSON" }, { status: 400 }); } try { const context = { user: await authenticate(request), request }; const result = path === api.routes.query ? await api.handleQueryJson(body, context) : path === api.routes.read ? await api.handleReadJson(body, context) : await api.handleMutateJson(body, context); return Response.json(result); } catch (error) { if (error instanceof RindleApiError) { return Response.json({ error: error.message }, { status: error.status }); } console.error(error); return Response.json({ error: "Request failed" }, { status: 500 }); } }; } ``` `authenticate` must verify your session or token. An unverified `x-user` header is not authentication. If your framework already authenticates requests, pass that verified identity into the same handler context. A denied query throws a 403 API error. A denied mutation becomes a rejected mutation verdict and still advances the watermark. It normally returns through the successful JSON handler response, so inspect the verdict. `handleMutateJson` accepts one envelope or `{ envelopes: [...] }`. A batch runs in order. If a transport failure interrupts it, the client retries; the standard SQL backend absorbs the already-applied prefix. ## Pinned queries & the one-shot read A [pinned query](https://rindle.sh/docs/pinned-queries) keeps its maintained result with no subscribers. Configure dedicated public queries in `pinnedQueries`, then call `await api.assertPins()` at startup. The linked guide provides a complete example. Pins resolve under `pinUser` (default `undefined`), without a per-request context. They consume memory and maintenance work while idle. Do not substitute a shared pin for per-request authorization. `assertPins()` is idempotent for the same canonical query. Daemon materializations are not durable across restarts, so reassert pins after a boot-ID change. `pinFanout` can assert them on every live follower; without it, the configured daemon connection receives the requests. The current lease path also pins other argument combinations leased under any name listed in `pinnedQueries`. Use fixed or tightly bounded arguments for those names to avoid retaining unbounded results. A one-shot read passes the same named-query resolution and authorization as a lease. It reuses a pin only when the canonical query and visibility scope match. An unpinned read can keep a temporary materialization for `readIdleTtlMs`. Neither path creates a subscriber. The result contains `rows`, `cvMin`, and `queryKey`. It describes the changes applied by the serving engine; a follower can lag the write authority. SSR uses this result to seed a page before browser handoff. See [Server rendering](https://rindle.sh/docs/ssr) for readiness, visibility, and affinity. ## Talking to Rindle Ordinary applications configure `rindle` and let the API server own its transports. Advanced integrations can inject `daemon`, `database`, `sql`, or a mutation `backend`. Explicit fields override their corresponding derived transport. `database` creates an owned SQL client; `sql` accepts a caller-owned session. Without either, the legacy `daemonBackend` is the fallback. `HttpRindleDaemonClient` and `SplitDaemonClient` from `@rindle/daemon-client` provide custom control-plane connections. Keep the appropriate write-control connection when using room or lifecycle operations. A read-only follower client can serve named-query reads alongside a separate SQL mutation backend. `postgresBackend` is a separate preview integration. It runs mutators against Postgres while a gateway feeds Rindle followers. Its query and replay limitations differ from the standard SQL backend; read [Postgres as the source of truth](https://rindle.sh/docs/postgres-source) before using it. For bulk jobs, scripts, or writes with no browser prediction, use ordinary SQL. The [background-write guide](https://rindle.sh/docs/background-writes) explains transactional idempotency and mutation-envelope differences. ## Next steps - [The browser client](https://rindle.sh/docs/client) — connect the shared query and mutator contract. - [Isomorphic mutators](https://rindle.sh/docs/mutators) — define deterministic shared operations. - [Pinned queries](https://rindle.sh/docs/pinned-queries) — maintain server results between requests. - [Server rendering](https://rindle.sh/docs/ssr) — seed pages through one-shot reads. - [Deploying and scaling](https://rindle.sh/docs/deploy) — choose the data-tier topology. --- [View this page on Rindle](https://rindle.sh/docs/api-server) --- # Rindle SQL (@rindle/sql-client) Run SQL over HTTP with transactions, session consistency, and a Drizzle adapter. No browser client is required. **Rindle SQL** sends SQL statements to a Rindle deployment over HTTP and returns rows. Use it from server code, scripts, or the supported Drizzle adapter. A SQL request returns a snapshot. To receive later changes automatically, register a [live query](https://rindle.sh/docs/replica-and-views). SQL requests and live queries can use the same database. You can start with SQL and add synchronization when your application needs it. SQL statements use SQLite syntax and the [supported schema types](https://rindle.sh/docs/schema). ## Connect and run a query You need a running Rindle deployment and its server credentials. For local setup, use [`rindle dev`](https://rindle.sh/docs/rindle-cli). Rindle Cloud supplies credentials in its **Connect** panel. Keep the database token on your server. `@rindle/sql-client` runs in environments with the standard `fetch` API and has no runtime dependencies. Install it in your server package: ```sh pnpm add @rindle/sql-client ``` ```ts // scripts/sql-ready.ts import { createSqlClient } from "@rindle/sql-client"; const sql = createSqlClient({ url: process.env.RINDLE_URL!, authToken: process.env.RINDLE_DATABASE_TOKEN!, }); try { const response = await sql.execute("select 1 as ready"); console.log(response.result.rows); // [[1n]]: integer results default to bigint } finally { sql.close(); } ``` `rindle dev` injects both variables locally. Rindle Cloud's **Connect** panel supplies the same pair in production. The URL is the application ingress. Callers do not need to know which process owns the write master. ## Authentication `authToken` is a trusted credential with database-wide access. It does not provide per-user row authorization. Keep it in server code. A browser connects through your [API server](https://rindle.sh/docs/api-server), which authorizes its operations. `RINDLE_DATABASE_TOKEN` is the one application-facing server credential. The unified edge uses it for trusted SQL and API-server traffic. Private replication credentials remain an infrastructure detail and are not application configuration. ## Synced-app mutations The API server uses this transport internally for authoritative optimistic mutations. Configure its `rindle` option with the same URL and token, or use `rindle: {}` to read the environment. It derives the SQL and query-control clients. The [API server guide](https://rindle.sh/docs/api-server#the-server) shows the complete configuration with query and mutator registries. This keeps routine application setup to one URL and one token. Server-only mutator code can use `tx.sql` to execute or query raw SQL in the mutation transaction. A scoped mutator can use `scope.sql` for deliberate work outside that transaction. Import `createSqlClient` directly when ordinary SQL is itself the operation — scripts, migrations, ORM integration, admin work, or unrelated service code. The advanced API-server `sql` option still accepts an already-created, caller-owned session for testing or custom lifecycle management. Pure-write mutators stay on a one-request path. A mutator that reads lazily opens an interactive mutation transaction at its first read, so its accumulated write prefix, reads, later writes, and watermark commit share one transaction. A thrown business rule rolls that transaction back before the server commits the watermark alone. The browser's optimistic queue then advances. The underlying trusted-server methods are `executeMutation`, `beginMutation`, and `rejectMutation`. Each accepts `{ clientId, mid }` from the browser mutation envelope. The client does **not** accept `lmid`: that is authoritative server state, returned in `MutationReceipt` only after the mutation effects (or an lmid-only rejection) commit. These explicit methods are separate from ordinary `execute`/`begin`, so generic SQL never accidentally enters the optimistic mutation protocol. ## Consistency and read-your-writes Reads default to **session** consistency. Persist `getSessionCursor()` with an end-user session and seed the next request with `client.session(cursor)` to preserve read-your-writes across serverless invocations without mixing different users' fences. This function assumes a `user` table with string `id` and `name` columns. Its caller loads the previous cursor from that user's session and saves the returned cursor for the next request: ```ts // server/rename-user.ts import type { SqlClient } from "@rindle/sql-client"; export async function renameUser( sql: SqlClient, previousCursor: string | null, id: string, name: string, ): Promise { const session = sql.session(previousCursor); await session.execute({ sql: "update user set name = ? where id = ?", args: [name, id], }); return session.getSessionCursor(); } ``` The client-wide `consistency` setting is a read default. It never turns writes into consistency errors. A per-call `consistency` option is explicit read intent, so the server rejects it when its own SQL classifier finds a write or DDL statement. Reads are answered by the write authority itself — the replicator master in a fleet, the daemon in standalone. The TypeScript response identifies it in `routing.servedBy` (`"master"` or `"standalone"`); the underlying JSON wire field is `routing.served_by`. `session` and `strong` validate the canonical `w:` fence against the current head, while `eventual` deliberately skips the fence. A fleet *follower* refuses the entire `/v1/sql` surface with a `409` naming the master: its local commit counter cannot fence cursors minted in the master's sequence. Durable timeline ancestry across restore events is not yet encoded in this sequence-only cursor format. ## Transactions and retries Use `withTransaction` for statements that must commit together. This example assumes an `account` table with `id` and `balance` columns: ```ts await sql.withTransaction(async (tx) => { await tx.execute({ sql: "update account set balance = balance - ? where id = ?", args: [10, 1] }); await tx.execute({ sql: "update account set balance = balance + ? where id = ?", args: [10, 2] }); }); ``` The authoritative master executes interactive transactions on its HCTree connection pool. Multiple transactions can run concurrently while successful commits still receive one total journal order. Disjoint work can commit in parallel. `withTransactionRetry` is the opt-in callback replay surface for an OCC conflict. Interactive transaction callbacks are not replayed unless explicitly requested. Use `withTransactionRetry` when the callback is safe to re-run after an OCC conflict. On a standalone daemon, write transactions serialize on the single wal2 writer, but a transaction opened with `readOnly: true` is backed by its own wal2 reader snapshot: paging a long report holds no lock a write must wait for, and its snapshot stays stable while writes commit beside it. Each held snapshot pins wal2 checkpointing, so open read-only transactions are capped (`RINDLE_READ_TXN_SESSIONS`, default 4). Past the cap, a begin answers `503 read transaction capacity exhausted` rather than queueing — the same busy shape as the master's exhausted write-session pool — so close an open transaction or back off and retry. One-shot writes keep one idempotency key across the client's bounded automatic transport retries. If those attempts are all exhausted, the returned `TRANSPORT_ERROR` is outcome-unknown. Invoking the method again creates a new logical operation and a new key. An exhausted server-declared request retry is likewise downgraded at this API boundary because the one-shot key is not exposed for a later invocation. Use a declared migration identity or the typed transaction API when an application must recover an operation across a longer outage. Inside a typed transaction, an exhausted request-scope statement or commit keeps its operation ID: retry the same statement/batch or `commit()` call. A different statement or commit is refused while a statement retry is pending. `rollback()` remains available to abandon the transaction safely. ## Drizzle `@rindle/sql-client/drizzle` supplies the small structural client consumed by `drizzle-orm/libsql` 0.44.7. It is not a general `@libsql/client` implementation. The supported Drizzle peer is pinned exactly because this is a runtime structural seam, not a libSQL wire promise. Drizzle's 0.44.7 `libsql` entry point itself imports its optional `@libsql/client` peer eagerly. So an application using that entry point must install `@libsql/client` even though Rindle does not use its transport. Install the compatible ORM and its eager peer: ```sh pnpm add drizzle-orm@0.44.7 @libsql/client ``` ```ts import { drizzle } from "drizzle-orm/libsql"; import { createDrizzleClient } from "@rindle/sql-client/drizzle"; const client = createDrizzleClient({ url: process.env.RINDLE_URL!, authToken: process.env.RINDLE_DATABASE_TOKEN!, }); const db = drizzle(client); // Use db with your Drizzle table definitions. // Call client.close() when the owning application shuts down. ``` Top-level `write`, `deferred`, and `read` transactions are mapped to Rindle's typed transaction API. Savepoints/nested transactions, embedded replica sync, and the libSQL wire protocol are not supported. Use Rindle's migration workflow instead of Drizzle's libSQL migrator. Ordinary integer result columns become safe JavaScript numbers. For exact 64-bit columns, import `rindleBigint` from `@rindle/sql-client/drizzle` and use it in your Drizzle schema: ```ts import { sqliteTable, text } from "drizzle-orm/sqlite-core"; import { rindleBigint } from "@rindle/sql-client/drizzle"; export const externalRecord = sqliteTable("external_record", { id: rindleBigint("id").primaryKey(), title: text("title").notNull(), }); ``` `rindleBigint` emits `BIGINT` DDL and uses JavaScript `bigint` for binds and direct column results. SQLite expression results such as `max(id)` have no declared column type, so they use the safe-number path and reject unsafe integers. Use the native SQL client with `intMode: "bigint"` for exact expression results. An injected `SqlClient` must use the lossless bigint mode, not number or string mode. These SQL column types still have the live-query restriction below. ## Declared v1 value bounds - Safe integral `number` binds use SQLite's INTEGER storage class. Fractional and larger numeric `number` values use REAL. Use `bigint` when a larger integer is intended. - `bigint` binds preserve the full signed 64-bit range. Columns declared exactly `BIGINT` or `INT8` store and replicate these values without converting them to floating point. Their non-null cells must use SQLite's INTEGER storage class. - Ordinary `INTEGER` and other number columns use the engine's floating-point representation. Integer writes must round-trip through `f64` exactly or fail with `VALUE_UNSUPPORTED`. Use an exact `BIGINT`/`INT8` column when that bound is insufficient. - Maintained queries currently reject an `int64` column in their required columns, including primary keys, filters, ordering, and correlations. Exact SQL storage support does not imply browser live-query support. See [Schema and migrations](https://rindle.sh/docs/schema). - Binary/Blob bind values fail locally with `VALUE_UNSUPPORTED`. No byte-to-text coercion occurs. - Columns declared `BOOLEAN` currently accept only canonical SQLite integer cells `0`, `1`, or `NULL` on replicated writes. Other integer storage values fail with `VALUE_UNSUPPORTED` instead of being silently normalized to a boolean by the IVM engine. - Results use `bigint` integers by default. `intMode: "number"` rejects integers outside JavaScript's safe range, and `intMode: "string"` returns tagged integers as decimal strings. - `Infinity` and `-Infinity` use the v1 tagged JSON representation. `NaN` is unsupported. ## Regular expressions SQLite ships no built-in `regexp`, so stock SQLite answers `x REGEXP y` with *no such function*. Rindle registers one, which also lights up the infix operator: ```sql select id, title from issue where title regexp '(?i)^\[urgent\]'; ``` `regexp(pattern, text)` is the only registered function. Note the argument order — pattern first, haystack second — which is what SQLite's infix rewrite produces. The dialect is the Rust [`regex`](https://docs.rs/regex) crate, **not** ECMAScript. It accepts inline flags (`(?i)`), Unicode property classes (`\p{Greek}`), and POSIX classes (`[[:alpha:]]`), and treats `\d`/`\w`/`\s` as Unicode rather than ASCII. It rejects backreferences and lookaround outright — a pattern that works in JavaScript is not guaranteed to work here, or to mean the same thing. There is no backtracking, so a caller-supplied pattern cannot drive the server into exponential match time. Either NULL argument yields NULL, regardless of the other argument, so a regex predicate over a nullable column filters that row out instead of failing the statement. An invalid pattern *is* an error. REAL arguments are rejected rather than coerced, because SQLite and Rust do not render floats identically and a silent difference in rendering would be a silent difference in what matched; cast explicitly if you mean to match on a float's text. The function is resolved on every read surface over a database — `/execute-sql-read`, mutator SQL, and `rindle db` against a local file — so a query you check in the CLI behaves the same way when you send it. Referencing it from a partial index's `WHERE`, an expression index, or a `CHECK` is allowed but is a commitment about the file rather than the query: SQLite needs the function to compile any write touching that table, so ordinary tools that don't register it — including stock `sqlite3` — can still read the database but can no longer write to it. Generated columns are not supported by Rindle's replicated schema envelope. `regexp` is a **SQL-only** facility: registered queries are incrementally maintained by the engine's own filter set (see [supported query shapes](https://rindle.sh/docs/supported-queries-ts)), which has no regex operator, so a regex predicate belongs in a raw read rather than a maintained view. ## Schema Rindle SQL writes go through the same replicated schema envelope as the rest of the engine, so a table must have a declared primary key and use supported column types — see [the schema page](https://rindle.sh/docs/schema) for the DDL subset. `id TEXT PRIMARY KEY` and Drizzle's table-level `primaryKey({ columns })` are both accepted. What is rejected is an actual NULL primary-key cell. Use `rindle migrate apply` for versioned deploy migrations. Migration files can contain pure DDL or pure DML, but never both in one file. The CLI and master bind their kind and checksum to the apply-once identity. The public `SqlClient.migrate()` method remains a DDL-only primitive. See [Schema & migrations](https://rindle.sh/docs/schema#migrations) for destructive DDL, data backfills, and limits. For ad-hoc work, the CLI is the same client contract without application code: ```sh rindle sql "select count(*) from issue" rindle sql --file scripts/seed.sql ``` Call `sql.close()` when its owner shuts down. It rejects new work and aborts outstanding fetches. The platform owns connection pooling and keep-alive behavior. --- [View this page on Rindle](https://rindle.sh/docs/sql-client) --- # Run the daemon (rindled) Configure rindled as a standalone database authority or a read follower, and understand its network and recovery requirements. **`rindled`** is the long-running server that maintains live queries and streams changes to subscribers. Application code usually starts it through [`rindle dev`](https://rindle.sh/docs/rindle-cli). This page is for operators who configure or supervise the process directly. The daemon has two operating modes: - **Standalone authority** — one source-less daemon owns a wal2 SQLite file and serves reads, writes, migrations, SQL, materializations, and subscriptions at one origin. - **Fleet follower** — the daemon holds a read-only wal2 replica, derives deltas from a [`rindle-replicator`](https://rindle.sh/docs/deploy) master's ordered stream, and serves reads and subscriptions. Every write endpoint — and the whole public `/v1/sql` surface, reads included — remains fenced with a fail-closed 409 that names the master. The browser subscribes to the daemon's public WebSocket. Your [API server](https://rindle.sh/docs/api-server) authorizes named operations and drives its private HTTP control plane. In standalone, its read and write transports point at the same origin; in a fleet, a split client sends writes to the master and reads to a follower. See [the architecture](https://rindle.sh/docs/architecture) for how the three tiers fit and [deploying & scaling](https://rindle.sh/docs/deploy) for the durability and scale tradeoffs of each posture. This page covers the daemon itself. ## The binary `rindled` lives in the `rindle-server` crate and ships as a prebuilt, per-platform binary with [`@rindle/cli`](https://rindle.sh/docs/rindle-cli). For local dev you rarely invoke it directly — `rindle dev` renders your [`rindle.ncl`](https://rindle.sh/docs/rindle-cli), supervises the selected profile, and runs your application with topology-derived bindings. Use `rindle up` when you deliberately want only the data tier. It's the first thing the [synced-app quickstart](https://rindle.sh/docs/synced-app-quickstart) does. This page is the daemon in full — the config, the two planes, restart recovery, and the engine underneath — for when you run it yourself. To run a daemon directly (in production, or under your own supervisor), point a release, container, or otherwise supervised `rindled` binary at a JSON config: ```bash rindled --config follower.json # Or build from a source checkout (the manifest declares Apache-2.0): cd rust cargo build -p rindle-server --bin rindled --profile release-server ./target/release-server/rindled --config follower.json ``` Source builds use `release-server` so the daemon can contain a panic from an individual mutation. The ordinary release profile aborts the process on panic. The config declares its file, ports, auth, worker count, and posture. A follower has one change source — the write-master's fan-out stream: ```json { "db": "follower.db", "httpPort": 7600, "wsPort": 7601, "authToken": "dev-daemon-token", "nWorkers": 4, "sources": [ { "kind": "replicator", "name": "rindle-master", "url": "ws://127.0.0.1:7610/subscribe" } ] } ``` A standalone config is deliberately source-less and opts in explicitly. It needs a second, distinct bearer for the public SQL surface when exposed beyond loopback: ```json { "db": "standalone.db", "httpPort": 7600, "wsPort": 7601, "bindHost": "127.0.0.1", "authToken": "private-control-token", "sqlAuthToken": "server-sql-token", "nWorkers": 4, "standalone": true, "sources": [], "tables": [] } ``` Create application tables through [migrations](https://rindle.sh/docs/schema). The `tables` field is primarily useful to bootstrap a directly spawned daemon in a test or embedding. - **`db`** — the file-backed wal2 SQLite database (defaults to `rindle.db`). It is the authority in standalone and a replica in follower posture. - **`httpPort` / `wsPort`** — the control and subscription ports. `0` binds an ephemeral port (handy in tests — the chosen port comes back in the readiness signal). - **`authToken`** — the private control-plane bearer. A non-loopback bind refuses to start without it. - **`sqlAuthToken`** — the public `/v1/sql/*` bearer. Standalone requires this surface for `@rindle/sql-client`; it must differ from `authToken` when both are present. - **`nWorkers`** — IVM worker threads in the underlying `Cluster`. If omitted, the binary uses host parallelism, with a minimum of 2 and a fallback of 2. - **`defaultLeaseTtlMs`** — how long a materialization lease lives without renewal. - **`queryFamilies`** — group subscriptions that differ only in a root-level equality literal into one shared pipeline (a *parameterized query family*). On by default; `false` is the kill switch. Subscribers see the same frames either way. - **`familyShardBindings`** — how many partitions one family pipeline carries before the daemon opens another (on another worker) for new members. Default 2048. - **`joinPrecheckPerJoin` / `joinPrecheckPerGraph`** — the memory bounds of the join membership pre-check (a hash probe that skips the parent lookup a child-table write would otherwise cost every nested-relationship query): distinct parent keys one join may track, and the total across a worker's joins. On by default at 4096 / 65536; `joinPrecheckPerJoin: 0` turns it off. Subscribers see the same frames either way. - **`standalone` / `sources`** — choose exactly one posture. `standalone: true` requires zero sources. Follower posture requires exactly one `kind:"replicator"` source. Omitting the standalone flag never guesses from an empty list; it stays a follower and fails closed. A follower receives schema DDL from its master, while a standalone daemon applies it locally through `/migrate`. On a successful start `rindled` prints exactly **one** line of JSON to stdout, so a supervisor or test runner can wait on it: ```json {"ready":true,"httpPort":7600,"wsPort":7601} ``` ## Two listeners and trust surfaces The daemon exposes two network listeners, kept separate so the untrusted browser plane and trusted server-to-server traffic never share a door: | Plane | Port | Who connects | Carries | | --- | --- | --- | --- | | **Public WebSocket** | `wsPort` | browser clients | the normalized protocol — `init`, `subscribe` / `unsubscribe`, and cv-stamped snapshot + delta frames out | | **Private HTTP control** | `httpPort` | your API server / operator | all postures: `/materialize`, `/execute-sql-read`, `/dematerialize`, `/schema`, `/version`; standalone also: `/execute-sql-txn`, `/mutate-session/*`, `/reject-mutation`, `/migrate` | | **Public SQL over HTTP** | `httpPort` | trusted SQL clients / API server | `/v1/sql/*`, protected independently by `sqlAuthToken`; standalone owns reads and writes here — a follower refuses the entire surface | In follower posture, the **write** endpoints live on the [`rindle-replicator`](https://rindle.sh/docs/deploy) master. If you point a write at the follower, it refuses it with a fail-closed error that names the master. A misrouted write cannot silently vanish. The same 409 fences the entire public `/v1/sql` surface, reads included: session-consistency cursors are minted in the **master's** journal sequence, which a follower's local commit counter cannot fence, so honoring them would return silently stale rows or spurious `CURSOR_HISTORY_LOST`. Send all `/v1/sql` traffic to the write-master — in a fleet, the unified edge does this for you. Standalone enables its mutation, migration, session, and public SQL write surfaces, but not fleet change-source ingress such as `/apply-row-change-txn`. The control routes require `authToken` when one is set; `/v1/sql/*` requires the separate `sqlAuthToken`. These are operator-level topology details. A fleet edge can expose one `RINDLE_URL` and one server-only `RINDLE_DATABASE_TOKEN`, routing each call internally. A loopback standalone `rindle dev` connection also exports one application origin; a directly networked standalone API server configures its private daemon client and public SQL/database transport with their respective bearers. Browsers never speak either trusted surface directly. The low-level [`@rindle/daemon-client`](https://rindle.sh/docs/api-server#talking-to-rindle) package remains the typed client for custom self-hosted routing and supervisor integrations. ## Standalone durability contract Standalone deliberately has no HCTree journal, follower fan-out, `rindle-backup` plane, PITR, or automatic failover. Desktop/local software owns an ordinary SQLite snapshot (`VACUUM INTO`, the backup API, or an OS backup) and its RPO. A hosted micro deployment keeps one persistent volume and restores a completed provider volume snapshot into a replacement instance; recovery has downtime and may lose writes back to the advertised snapshot interval. A product-initiated snapshot before a destructive migration or risky upgrade must stop intake, drain or roll back open sessions, and quiesce/close the writer first. Provider-scheduled snapshots should be advertised only after a restore drill has captured an active wal2 database, restored it to a new volume, passed SQLite integrity checks, and re-proved view-after-write equals a fresh query. A standalone wal2 file cannot be promoted in place to an HCTree master. Moving to a fleet is an export/import into a new store followed by a routing cutover. Choose the fleet profile from the start when continuous journal recovery, followers, or read fan-out are requirements. ## Restart recovery: the boot id `rindled` keeps **no durable materialization state** — on restart it has the data (it's file-backed) but no live queries or pins. So it stamps every control-plane response with a **boot id** header that changes when it restarts. The `HttpRindleDaemonClient` surfaces it via `onBootId`, and your API server re-asserts its pinned queries when it fires: ```ts const daemonToken = process.env.RINDLE_DAEMON_TOKEN ?? process.env.RINDLE_DATABASE_TOKEN!; const daemon = new HttpRindleDaemonClient({ baseUrl: process.env.RINDLE_URL!, headers: { authorization: `Bearer ${daemonToken}` }, onBootId: () => api.assertPins().catch(console.error), // re-warm after a restart }); ``` The hook rides responses you already make, so there's no polling — the next control-plane call after a restart re-establishes the warm set. ## Under the hood: the Cluster `rindled` runs the multi-threaded **`Cluster`** engine from `rindle-replica`. Where the single-thread [`Db`](https://rindle.sh/docs/replica-and-views) advances every query on one thread, `Cluster` shards queries across a pool of IVM worker threads behind a single writer/coordinator. In standalone, writes serialize on that wal2 writer, while each read-only public SQL transaction runs beside it on its own wal2 reader snapshot — see [Rindle SQL](https://rindle.sh/docs/sql-client#transactions-and-retries). On a follower, pre-formed transactions arrive over the master's stream. The same per-transaction handshake keeps IVM derivation correct: 1. The coordinator opens its controlled write transaction. Standalone captures SQL row changes; a follower receives captured changes from its source. 2. Workers pin read-only snapshots of the pre-commit database. Captured changes stream through a batch overlay as each worker derives its query deltas. 3. Workers can send provisional `Changed` slices before the writer commits. A transaction can have several slices for one query. 4. After a successful commit, each worker sends `Progressed` after its changes. The drain computes connection progress, and clients release staged data through that confirmed point. A query lives on one worker. Its changes retain order, but receiving a data slice alone does not establish a committed result. A query fault is terminal: the consumer discards its provisional data and obtains a fresh subscription. An abort after streaming can also cause this recovery. The derivation connections do not replay writes or roll back a second copy of the transaction. SQLite supplies the stable snapshot, and the overlay supplies the changing rows. See [the embedded runtime](https://rindle.sh/docs/replica-and-views#scale-out-readers) for the raw `Cluster` delivery contract. ## The query planner The daemon runs the cost-based join-flip [planner](https://rindle.sh/docs/how-it-works#query-planner) — it annotates each flippable `EXISTS` with a `flip` decision before lowering, and picks the cheaper drive side from a real-SQLite cost model. It is **result-preserving** (only the work changes, never the rows) and is **on by default** (`Cluster::open` enables it). Opting out is in-process only today, via `Cluster::open_with_planning(path, n, false)`, not yet through the config file. ## Scope `rindled` is the productionizing read server, but it is young. The replica's schema constraints apply — plain tables without triggers or generated columns, and adapter-specific numeric restrictions. See [replica and views](https://rindle.sh/docs/replica-and-views). The database bearer is server-wide. Finer-grained authz lives in your [API tier](https://rindle.sh/docs/api-server). This page runs **one daemon**. Standalone is intentionally one authority and one file; starting a second writable copy would create two authorities, not a replica. To add continuous journal recovery or scale reads across affinity-placed followers, move to the fleet profile described in [deploying & scaling](https://rindle.sh/docs/deploy). That move is an export/import into a new HCTree store followed by a routing cutover, not an in-place promotion of the standalone wal2 file. ## Next steps - [The API server](https://rindle.sh/docs/api-server) — the tier that drives the control plane. - [The browser client](https://rindle.sh/docs/client) — what subscribes to the ws plane. - [`@rindle/cli`](https://rindle.sh/docs/rindle-cli) — the local supervisor, migration, and schema-gen toolchain that ships the daemon binary for JS/TS projects. - [Synced-app quickstart](https://rindle.sh/docs/synced-app-quickstart) — `rindled` booted and wired to both other tiers. - [Crates & API map](https://rindle.sh/docs/crates) — `rindle-replica` (`Db` / `Cluster`), `rindle-server`, and `rindle-planner`. - [Deploying & scaling](https://rindle.sh/docs/deploy) — choose standalone or a read-scaled fleet and understand each durability contract. - [Connect your app to Rindle Cloud](https://rindle.sh/docs/cloud-connect) — the managed counterpart of this page's internal planes: one application URL and token. --- [View this page on Rindle](https://rindle.sh/docs/daemon) --- # @rindle/cli The npm-installed Rindle toolchain: local fleet and app lifecycle, SQL, local HCTree/wal2 inspection, migrations, schema generation, backup, and Rindle Cloud deployment. `@rindle/cli` is the JS/TS developer toolchain for Rindle. It installs the `rindle` CLI together with matching `rindle-replicator`, `rindled`, and `rindle-dev-edge` binaries as prebuilt, per-platform npm artifacts — no Rust toolchain required for app development. Use it to: - scaffold a local topology with `rindle init` - own the complete local data-tier + app lifecycle with `rindle dev` - supervise only the local data tier with `rindle up` - query or modify a database with `rindle sql` - inspect a local standalone wal2 file or HCTree master/follower files with `rindle db` - apply pure-DDL and pure-DML migrations with `rindle migrate` - generate `@rindle/client` TypeScript schema with `rindle schema gen` - deploy, link, and migrate Rindle Cloud apps ## Install Use Node.js 22 or later, as required by the package manifest. ```bash pnpm add -D @rindle/cli # or npm i -D @rindle/cli ``` Run it through the package manager's bin resolution: ```bash npx rindle --help pnpm exec rindle status ``` The package selects the platform artifacts it needs through optional dependencies. All four binaries are versioned and installed together, so `rindle dev` can always find the master, follower, and edge it was released with. ### Supported platforms Prebuilt binaries ship for **macOS** (Apple Silicon + Intel) and **Linux** (x86_64 + arm64, glibc + musl). **Windows: use WSL2.** The CLI has no native Windows binaries. WSL2 runs the Linux binaries and the `rindle dev` processes inside your distribution. A Windows browser can reach the development server through WSL2's `localhost` forwarding. The browser engine uses WebAssembly and runs independently of the server operating system. One rule that matters: > **Keep the database on the WSL filesystem (`~/`), never under `/mnt/c`.** Windows > drives are exposed to WSL through a translation layer that can't reliably back a > memory-mapped database — the same reason not to put SQLite on a network share. > Your source tree can sit wherever you like. The data directory can't. It'll also be > dramatically faster on the Linux side. WSL1 is not supported: it emulates the memory-mapping and file-locking calls the engine depends on, rather than implementing them. ## Know where a command runs The CLI separates project files, local processes, app connections, and Rindle Cloud. “Remote” is not a mode: `--url` means a direct app ingress and can point to localhost, a self-hosted server, or a managed app's Connect URL. `--cloud` means the authenticated Rindle Cloud control-plane proxy. | Command | Scope | App target selectors | | --- | --- | --- | | `context` | explains available targets; contacts none | none | | `init`, `render` | project files | none | | `migrate list`, `migrate create` | local migration files | none | | `indices suggest` | local shapes file; optionally compares live indexes | `--local` or `--url`; no target is valid | | `up`, `dev`, `exec`, `ps`, `stop` | local fleet/process lifecycle | none | | `db` | local HCTree/wal2 database file | none; choose a role, component, or path | | `version`, `status`, `stats`, `health`, `schema` | running app | `--local` or `--url` | | `sql`, `analyze query`, `restart`, `dematerialize`, `re-bootstrap` | running app | `--local` or `--url` | | `migrate apply`, `migrate status` | running app | `--local`, `--url`, or `--cloud` | | `login`, `logout`, `whoami`, `deploy`, `link` | Rindle Cloud control plane | none; these are always Cloud commands | | `backup …` | backup store or downloaded object | none | For a running-app command, choose exactly one target: ```bash rindle status --local # this project's rendered/authored topology rindle status --url https://example.test # one directly reachable ingress rindle migrate status --cloud # app bound in .rindle/cloud.json ``` With no selector, `RINDLE_URL` wins. Otherwise the CLI discovers `rindle.json` or `rindle.ncl` in the project. If neither exists, it fails with a target-selection error. There is deliberately no implicit `localhost:7600`. `--local` forces topology discovery even when `RINDLE_URL` is set. Run `rindle context` to see the direct connection environment, local read/write endpoints, Cloud binding, and default selection without contacting any of them. `rindle context --json` is suitable for scripts. `--remote` remains a deprecated alias for `--cloud` during migration. ## Local dev loop Most apps use one command for the complete local lifecycle: ```bash npx rindle init npx rindle dev --migrate --gen shared/schema.gen.ts -- vite dev ``` `rindle dev` evaluates `rindle.ncl` once and starts its selected profile. Standalone is one write-owning `rindled`; replicated is the write-master + follower(s) + local fleet edge. The CLI waits for every public read component, applies pending `migrations/*.sql`, waits for topology-specific visibility, generates `shared/schema.gen.ts`, and only then starts the app. It injects the unified `RINDLE_URL` + `RINDLE_DATABASE_TOKEN` connection, watches requested migration/schema inputs, forwards signals, and tears down the app and data tier together. Use `rindle up` when you deliberately want only the data tier. `rindle exec` remains a compatibility adapter for running a one-shot command with topology-derived bindings, but normal development no longer needs either a second supervisor or a readiness script. ## Choose standalone or replicated `rindle init` keeps the established replicated default. Choose standalone explicitly for a desktop/local or deliberately single-node app: ```nickel # rindle.ncl { profile = "standalone", } ``` That render contains one `kind = "rindled"` component. Its `readHttpUrl` and `writeHttpUrl` are identical, its subscription WebSocket points at the same process, and there is no replicator or development edge. Migrations and schema generation both resolve to that daemon. `profile = "replicated"` renders the HCTree master, one or more followers, and the stable edge as before. Standalone deliberately rejects `backupGeneration`: it has no Rindle journal backup plane. Use an app/OS SQLite snapshot for desktop/local recovery, or qualified provider volume snapshots for a hosted micro deployment. A standalone wal2 file cannot be promoted in place into the fleet's HCTree master; migration to replicated is an export/import and routing cutover. ## Running several projects at once Local ports are allocated **per project**, not fixed. Each project gets a 100-wide block, chosen from the path of the directory holding `rindle.ncl` and remembered in `~/.rindle/ports.json`. So two Rindle projects — or two git worktrees of one project — can run at the same time without contending for a port. Within the block: | Component | Port | | --- | --- | | standalone `rindled` | `portBase` (control + SQL), `portBase + 1` (ws) | | follower *i* (private `rindled`) | `portBase + i*2` (control), `portBase + 1 + i*2` (ws) | | replicator (write-master) | `portBase + 11` (control), `portBase + 10` (fan-out ws) | | fleet edge (the app-facing URL) | `portBase + 50` | The standalone row is mutually exclusive with the three replicated rows. You don't need these numbers: `rindle dev` injects `RINDLE_URL`, `rindle.json`'s `bindings` carries the resolved URLs, and `rindle render` / `rindle up` print them. To pin a block instead, set `portBase` in `rindle.ncl` — `portBase = 7600` reproduces the fixed ports used before allocation. Each project also carries a **fingerprint** of its root path, which its daemons advertise on `GET /version` and the CLI asserts on every control-plane call. If a command reaches a daemon from a different project, it is refused with a `409` naming both — rather than, say, applying one app's migrations to another app's database. This is why the fingerprint is derived from the path and not from the app name: two worktrees of one repo share a name and differ only by where they live. ``` $ rindle migrate apply daemon error 409: wrong project: this rindle-replicator write-master serves 'my-app' (3b90321256fd), but the command came from a DIFFERENT checkout of 'my-app' (3b90621256fd). Refusing: applying it would have written one project's migrations into the other's database. ``` Starting a fleet whose ports are already taken fails immediately and names the holder, instead of retrying in the background: ``` $ rindle up ✖ cannot start 'my-app' — 5 of its port(s) are already in use 127.0.0.1:20911 (replicator) held by rindle app 'other-app' (7b20e8449c31) Another Rindle fleet ('other-app') is running and owns these ports. ``` The **replication** connection is fenced the same way: a follower stamps its fingerprint on the `subscribe` it sends to the write-master, and a master serving a different project refuses it before sending a single frame. Without that, a follower whose own master lost the port race replicates the other project's entire journal into its database. It then serves those rows as its own. The refusal is terminal, not a retry — the follower halts that source and says so, since reconnecting only re-asks the same wrong master. Peers that send no identity — a browser client, `@rindle/sql-client`, `curl`, a BYO relay consumer — are never fenced. Neither are deployed daemons, which carry no project identity at all. To drive another project's daemon on purpose, set `RINDLE_PROJECT_ID` to its fingerprint (or to the empty string to assert nothing). ### Upgrading from the fixed ports Before this, every project rendered the same loopback ports: the follower on `:7600`/`:7601`, the write-master on `:7610`/`:7611`, the fleet edge on `:7650`. Your fleet moves to its own block on the next `rindle up`, so **anything that hardcoded one of those numbers stops working**. Two ways forward: - **Recommended — stop hardcoding.** `rindle dev` injects the resolved URLs as environment (`RINDLE_URL`, `RINDLE_DAEMON_URL`, `RINDLE_REPLICATOR_URL`, …) and `rindle.json`'s `bindings` carries them for anything that reads the manifest directly. Prefer a hard failure over a default: a literal `?? "http://127.0.0.1:7600"` no longer points at nothing, it points at *whatever other project holds that port*. An app-tier client sends no project identity, so nothing stops it reading the wrong database. - **Pin the old block.** `portBase = 7600` in `rindle.ncl` reproduces the previous ports exactly. Fine for a single-project machine. It gives up the "two checkouts at once" property. Nothing about a deployed fleet changes: production renders carry no project identity and no allocation, and `rindle deploy` is unaffected. ## Inspect local database files Stock `sqlite3` cannot open Rindle's HCTree master or wal2 daemon files. `rindle db` uses the same Bedrock SQLite build as the runtime and opens the selected file physically read-only: ```bash # Resolve these roles through rindle.json and each component's generated config: npx rindle db standalone npx rindle db master npx rindle db follower npx rindle db follower-0 "SELECT * FROM issue LIMIT 10" # An explicit path is also accepted; --json makes one-shot output machine-readable: npx rindle db ./data/follower.db --json "SELECT * FROM _rindle_source_offsets" ``` With no statement and a terminal on stdin, the command opens an interactive shell. It supports `.tables`, `.schema [name]`, `.indexes [table]`, `.mode table|json`, and `.quit`. SQL can instead come from `--file` or piped stdin. Use `--manifest ` when the rendered manifest is not `./rindle.json`. If a fleet has multiple followers, use the component name rather than the ambiguous `follower` alias. The command opens with `SQLITE_OPEN_READ_ONLY`, forbids `ATTACH`, and rejects any statement SQLite marks writable. It never changes journal mode or checkpoints the source. A stopped wal2 database whose shared-memory sidecar has already disappeared can refuse a strictly read-only connection. Start its follower or inspect a restored portable base instead. Use `rindle sql` for authoritative application reads and writes over the live ingress. `rindle db` is the local operator/debugging surface. ## Migrations and schema generation Under `rindle dev`, migrations target the manifest's write owner and schema generation targets its read owner. Those are the same `rindled` in standalone, or the master and a follower in replicated. Local one-shot commands discover the roles from `rindle.ncl`, so they need neither hard-coded ports nor a nested `rindle exec`: ```bash npx rindle migrate create init npx rindle migrate apply npx rindle migrate status npx rindle schema gen --out shared/schema.gen.ts ``` Each migration file is classified as pure DDL or pure DML. Mixed files fail before any request. Destructive DDL (`DROP TABLE`, `DROP COLUMN`, and `DROP INDEX`) is accepted with a `[destructive]` notice. The reviewed file is the consent. DML is evaluated once on the standalone authority or fleet master. In a fleet, followers receive captured row deltas, not the SQL text. `migrate list` and `migrate status` show and verify each file's kind and checksum. For Rindle Cloud, `deploy` or `link` first records a non-secret app binding, then `--cloud` sends migrations through the authenticated Cloud proxy: ```bash npx rindle login npx rindle deploy --migrate # Or bind an SQL app created in the dashboard: npx rindle link app_… npx rindle migrate status --cloud npx rindle migrate apply --cloud ``` For a directly reachable self-hosted ingress, use the same application connection as your server: ```bash npx rindle schema gen \ --url "$RINDLE_URL" \ --token "$RINDLE_DATABASE_TOKEN" \ --out shared/schema.gen.ts ``` `RINDLE_DATABASE_TOKEN` is server/operator-only. Never ship it to the browser. Browser clients call your API server, and authorized query leases return a public WebSocket endpoint plus a placement ticket. ## `rindle dev` vs. `rindle up` vs. `rindled` `rindled` is either a standalone authority or a read-only follower. In the replicated profile, the `rindle-replicator` write-master feeds it. `rindle up` renders `rindle.ncl` and supervises the selected profile. Add `--migrate` to apply migrations, `--gen ` to generate schema, and `--watch` to repeat those operations when inputs change. `rindle dev` adds readiness gates, the application process, unified bindings, and one signal/teardown boundary. For application development, prefer `rindle dev`. For production, run the selected profile under your real process supervisor or use Rindle Cloud: - local dev: `npx rindle dev --migrate --gen shared/schema.gen.ts -- vite dev` - self-hosted production: run one standalone daemon or the master, followers, and edge from release artifacts, container images, or your own supervisor - managed production: `rindle deploy` a Sync topology or `rindle link` a dashboard-created SQL app, then use `migrate --cloud`. ## Command reference Running-app commands use the shared target selection described above. Aliases are in parentheses. | Command | Scope | What it does | | --- | --- | --- | | `context` (`ctx`) | explain only | show local, direct, and Cloud targets plus the default | | `status` · `stats` | running app | up/down + commit + boot id + the live counters; repaint with `--watch` | | `version` | running app | liveness + deployed commit (no token needed) | | `health` / `ready` | running app | liveness (+ auth) probe with a meaningful exit code | | `schema [show]` | running app | the deployed base-table shape | | `schema gen` (`generate`) | running app | emit the `@rindle/client` schema TS — `--out ` (default stdout), `--import-from `, `--schema-const ` | | `migrate apply` (`up`) | running app | apply ordered pure-DDL or pure-DML `*.sql`; `--cloud` selects the bound Cloud app | | `migrate list` (`ls`) | project files | list local migrations with kind and checksum | | `migrate create ` (`new`) | project files | scaffold a new migration file | | `migrate status` | running app | verify local kind/checksum against the applied journals; accepts `--cloud` | | `sql []` | running app | run SQL from an argument, `--file`, or stdin; multiple statements form one atomic batch | | `db []` | local DB file | inspect a standalone wal2 file, HCTree master, or wal2 follower read-only; target a role, component, or path | | `init` · `render [rindle.ncl]` | project files | scaffold or render the local topology | | `dev -- ` · `up` · `exec -- ` | local runtime | own/supervise the selected local profile or run with topology-derived bindings | | `ps` · `stop` (`kill`) | local runtime | list / stop running Rindle processes (`stop --all`, or by pid) | | `restart` · `dematerialize` · `re-bootstrap` | running app | lifecycle, cleanup, and recovery operations | | `login` · `logout` · `whoami` | Cloud control | Rindle Cloud auth (browser device flow; `$RINDLE_CLOUD_TOKEN` overrides for CI) | | `deploy` · `link ` | Cloud control | provision or bind an app and write `.rindle/cloud.json` | `rindle --help` prints the full list, including the fleet/topology and recovery commands (`render`, `dematerialize`, `re-bootstrap`). ## Flags & environment App-target precedence is **explicit selector → `RINDLE_URL` → local topology → error**. | Flag | Env var | Default | Meaning | | --- | --- | --- | --- | | `--local` | — | off | force this project's `rindle.json`/`rindle.ncl` target, ignoring `RINDLE_URL` | | `--url ` | `RINDLE_URL` | local topology, then error | connect directly to one Rindle ingress. There is no fixed-port default: ports are allocated [per project](#running-several-projects-at-once), so a local fleet is reached through its derived URL | | `--token ` | `RINDLE_DATABASE_TOKEN` | *(none)* | unified server/operator bearer; legacy `RINDLE_TOKEN` / `RINDLE_DAEMON_TOKEN` remain fallbacks | | `--cloud` | — | off | (`migrate apply/status`) use the bound app through Rindle Cloud | | `--remote` | — | off | deprecated alias for `--cloud` | | `--topology ` | — | `rindle.json`, then discovered `rindle.ncl` | explicit local topology for a running-app command | | `--cloud-url ` | `RINDLE_CLOUD_URL` | `https://cloud.rindle.sh` | Rindle Cloud control plane | | `--dir ` | `RINDLE_MIGRATIONS_DIR` | `migrations` | the migrations directory (`migrate *`) | | `--out ` | — | stdout | where `schema gen` writes | | `--gen ` | — | — | (`dev`/`up`) regenerate the schema TS to `` after `--migrate` — **always takes the path** | | `--json` | — | off | machine-readable output | | `--watch`, `-w` | — | off | repaint on an interval (`status`/`stats`); under `up`, re-apply/regen on change (`dev` watches requested inputs automatically) | | `--interval ` | — | `2` | the `--watch` repaint interval | | `--timeout ` | — | `5` | per-request timeout | | `--manifest ` | — | `rindle.json` | (`db`) rendered fleet manifest used to resolve master/follower component paths | | `--daemon-bin

    ` | `RINDLE_DAEMON_BIN` | sibling of `rindle`, then `$PATH` | (`dev`/`up`) explicit path to the `rindled` binary | | — | `RINDLE_REPLICATOR_BIN` | sibling of `rindle`, then `$PATH` | (`dev`/`up`) explicit path to the write-master binary | | — | `RINDLE_DEV_EDGE_BIN` | sibling of `rindle`, then `$PATH` | (`dev`/`up`) explicit path to the native local fleet-edge binary | | — | `RINDLE_BIN_DIR` | the platform npm package | (npm wrapper) a directory of locally built binaries to use instead of the prebuilt ones | ## Supervise from Node If you need to supervise the npm-installed daemon from Node, import the package helpers: ```ts import { rindledBinaryPath, spawnRindled } from "@rindle/cli"; const child = spawnRindled(["--config", "./follower.json"]); console.log(rindledBinaryPath()); ``` ## Package scripts If you want a package script, make it forward to the CLI: ```json { "scripts": { "rindle": "rindle", "dev": "rindle dev --migrate --gen shared/schema.gen.ts -- vite dev" } } ``` Then run: ```bash npm run dev # or npm run rindle -- status ``` ## Next steps - [Scaffold with create-rindle](https://rindle.sh/docs/create-rindle) - a TanStack Start app that uses this toolchain for its dev loop. - [Schema & migrations](https://rindle.sh/docs/schema) - the SQL-first migration and schema-gen workflow. - [Run the daemon](https://rindle.sh/docs/daemon) - the daemon's config, two network planes, and restart behavior. - [Synced-app quickstart](https://rindle.sh/docs/synced-app-quickstart) - the manual app setup that uses `@rindle/cli` directly. --- [View this page on Rindle](https://rindle.sh/docs/rindle-cli) --- # Performance Read measured query-maintenance costs, benchmark conditions, and comparisons across workloads. Incremental view maintenance updates existing query results after data changes. Its cost depends on the query, indexes, affected rows, and result representation. One source change can affect many joined rows or require work to refill a window. This page summarizes two recorded benchmarks. The numbers describe those workloads and environments. They are not latency guarantees for a browser, database commit, network subscription, or complete user interaction. The engine's contract is **correctness**: a maintained view equals a fresh query over the same data. It does not guarantee constant-time or sub-microsecond writes. ## In-process maintenance over Chinook The [Chinook profile](https://github.com/rindle-sh/rindle/blob/main/CHINOOK-PERF.md) compares the native Rust engine with in-memory and SQLite sources. It scales the music dataset by 1×, 10×, and 30×, up to roughly 124,000 source rows. The following results use the **in-memory source in native Rust**, not WebAssembly. Each timed operation adds and removes one row from an already materialized query. The harness reports the minimum across six timing rounds after warmup. | Pattern | Scale 1 | Scale 30 | | --- | ---: | ---: | | Filtered match (`push_filter_match`) | **432 ns** | **7.7 µs** | | Row outside the top-N window (`push_limit_outside`) | **419 ns** | **1.1 µs** | | Row inside the top-N window (`push_limit_inside`) | **1.9 µs** | **3.3 µs** | | Nested child (`push_nested_child`) | **2.5 µs** | **4.0 µs** | | Changed `EXISTS` result (`push_exists_flip`) | **883 ns** | **2.0 µs** | These measurements vary by pattern and scale. The nested-child case grows from 2.5 to 4.0 µs, while the filtered-match case grows from 432 ns to 7.7 µs. A row outside a limited result does less work than one that enters or displaces rows in that result. The profile measures hydration separately. Hydration builds the query, produces its initial result, and reads that result. Some small cases take hundreds of microseconds, while larger or nested cases take tens or hundreds of milliseconds. The complete matrix also records slower SQLite cases and their query plans. ## WebAssembly footprint and execution The WebAssembly package uses the same Rust engine with a browser binding. Native Rust timings do not include that binding, JavaScript projection, or UI rendering. - A recorded optimized artifact is about **211 KiB gzipped**. The current [size test](https://github.com/rindle-sh/rindle/blob/main/packages/wasm/test/bundle-size.mjs) sets budgets of 220 KiB gzipped and 575 KiB raw. - The `rindle` core needs no C toolchain. SQLite integrations have separate native dependencies. - Each core graph belongs to one thread (`!Send`). Multi-threaded runtimes use independent engines and message passing. Artifact size depends on the release, feature set, and optimization settings. The size test measures the WebAssembly binary, not the entire application bundle. ## Recorded comparison with TanStack DB The [comparison results](https://github.com/rindle-sh/rindle/blob/main/apps/bench-tanstack/RESULTS.md) use Rindle's WebAssembly store and TanStack DB **0.6.8**, under Node **22.22.2**. Both receive the same generated dataset: 1,000 users, 10,000 issues, and 50,000 comments. Both have indexes for the measured queries. The tables show queries with at most 50 top-level rows. Related rows can increase the total result size. Dataset loading is outside the timed region. ### Hydrate from scratch (ms, lower is better) Hydration includes query construction, materialization, and a read of the result. The reported value is the minimum of four rounds. | Query | TanStack DB | Rindle | Speedup | | --- | --: | --: | --: | | list: newest 50 open | 2.11 | 1.46 | **1.45×** | | list + author | 8.03 | 1.35 | **5.93×** | | list + comment count | 9.90 | 1.49 | **6.64×** | | list + 3 recent comments | 7.92 | 1.96 | **4.05×** | | issue detail + comments | 7.19 | 0.534 | **13.45×** | ### Incremental update (ms per add-and-remove pair, lower is better) Each operation adds and removes one row that affects the query. The reported value is the minimum per-pair time across three rounds of 25 pairs. These values measure two writes, not one. | Query | TanStack DB | Rindle | Speedup | | --- | --: | --: | --: | | list: newest 50 open | 5.36 | 0.113 | **47×** | | list + author | 6.61 | 0.133 | **50×** | | list + comment count | 25.29 | 0.086 | **295×** | | list + 3 recent comments | 24.80 | 0.129 | **192×** | | issue detail + comments | 24.91 | 0.072 | **347×** | Rindle is faster in these recorded bounded-result cases. Both products use incremental maintenance, so these differences are not an incremental-versus-recompute comparison. The results do not establish how every query, version, or machine compares. Full-result hydration has different results. In the recorded 10,000-issue scan, TanStack DB takes **54.83 ms** and Rindle takes **227.50 ms**. Transferring rows across the WebAssembly boundary contributes to Rindle's cost. Large scans are a valid workload, and applications that need them must measure that path too. The [full matrix](https://github.com/rindle-sh/rindle/blob/main/apps/bench-tanstack/RESULTS.md) also includes relationships and aggregates over complete result sets. Its [methodology](https://github.com/rindle-sh/rindle/blob/main/apps/bench-tanstack/README.md) describes query equivalents, parity checks, indexes, and timing boundaries. ## Reproduce the measurements Use a repository checkout and its pinned toolchain. For the Chinook benchmark, first download the SQL dataset: ```bash curl -fsSL -o /tmp/Chinook_Sqlite.sql \ https://github.com/lerocha/chinook-database/releases/download/v1.4.5/Chinook_Sqlite.sql ``` Run the Rust benchmark from the repository's `rust/` directory: ```bash cd rust cargo run -p rindle-sqlite --release --features fast-alloc --example bench_chinook_rs ``` The [Chinook harness](https://github.com/rindle-sh/rindle/blob/main/rust/rindle-sqlite/examples/bench_chinook_rs.rs) accepts `CHINOOK_SQL` and `CHINOOK_SCALES` for a different file or scale selection. The historical profile uses older crate names. The command here uses the current workspace member. For the TanStack comparison, run these commands from the **repository root**: ```bash pnpm install pnpm run build:wasm SCALE=large pnpm --filter @rindle/bench-tanstack bench ``` This benchmark writes `apps/bench-tanstack/RESULTS.md`. Its README lists the round-count and dataset controls. Compare results with the same dataset, package versions, and machine conditions. ## Costs to include in your workload - **Initial materialization.** A new or rebuilt view must read enough data to produce its result. Filters, joins, sorting, and indexes determine that work. - **Fan-out and window maintenance.** One changed row can affect many results. A removed window row can require a replacement lookup. - **SQLite query planning.** The Chinook profile records expensive correlated `EXISTS` probes without suitable statistics, and expensive window refills without a sort index. Its measured `ANALYZE` experiment improves the former workload. The [indexing guide](https://github.com/rindle-sh/rindle/blob/main/docs/INDEXING.md) explains the relevant indexes. - **Result delivery.** Projection, serialization, JavaScript allocation, rendering, and subscriber work contribute to application latency. - **Storage and sync.** Durable commits, replication, transport, and optimistic rebase add work outside these engine benchmarks. ## Next steps - [How it works](https://rindle.sh/docs/how-it-works) — the engine's query and change model. - [The browser client](https://rindle.sh/docs/client) — local results, predictions, and server confirmation. - [Supported queries](https://rindle.sh/docs/supported-queries) — query shapes and their restrictions. --- [View this page on Rindle](https://rindle.sh/docs/performance)