# Rindle — Use the engine > Live-query primitives, browser and Rust setup, SQL, and the database runtime. 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 "Use the engine" 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) --- # Reactive queries in the browser Use the wasm client to create an in-memory browser store, write rows, and subscribe to a typed live query without a server. `@rindle/wasm` runs Rindle's incremental query engine inside a browser tab. It provides an in-memory database, a typed query builder, and live query results. You supply the rows and control their lifetime. Use it for a browser-only application, local derived data, or a first experiment with the query API. This store has no network connection or built-in persistence. A reload loses its rows. It does not need an API server, named queries, or optimistic mutators. The browser also supports remote clients, with or without optimistic writes. [Choose a browser client](https://rindle.sh/docs/browser-clients) explains those alternatives. ## Install Use a TypeScript browser application with a bundler that can load WebAssembly assets, such as Vite. Install the package: ```sh pnpm add @rindle/wasm ``` `@rindle/wasm` re-exports the schema and query APIs from `@rindle/client`. You can import this example's APIs from one package. ## Create a store and observe a query Put this example in your browser entry module. It defines one table, opens a live query, and makes three writes. Watch the browser console for each result. ```ts // src/main.ts import { boolean, createSchema, createWasmStore, number, string, table, } from "@rindle/wasm"; const issue = table("issue") .columns({ id: number(), title: string(), open: boolean() }) .primaryKey("id"); const schema = createSchema({ tables: [issue] }); const store = await createWasmStore(schema); const view = store.query.issue .where.open(true) .orderBy("id", "asc") .limit(20) .materialize(); const unsubscribe = view.subscribe((rows) => console.log(rows)); const first = { id: 1, title: "Ship it", open: true }; await store.write((tx) => tx.add("issue", first)); const renamed = { ...first, title: "Ship the browser example" }; await store.write((tx) => tx.edit("issue", first, renamed)); await store.write((tx) => tx.remove("issue", renamed)); unsubscribe(); view.destroy(); ``` The listener prints four results: an empty array, the new issue, the renamed issue, and an empty array. Each callback receives the complete current result. The engine computes changes internally; you do not apply them yourself. The example releases the query after the last write. In an application, keep the view while its screen needs it, then run the same cleanup when that screen closes. ## Understand the three objects The **schema** declares the tables, column types, and primary keys. This standalone example defines its schema in TypeScript. A database-backed app instead generates the server schema from SQL. See [Schema and migrations](https://rindle.sh/docs/schema). The **store** owns one local engine. `createWasmStore(schema)` initializes the wasm module and creates the store. Initialization is asynchronous; local query maintenance is synchronous after that. The **view** is one maintained query result. Calling `materialize()` registers the query and hydrates it before returning. `view.data` contains its current rows. `view.subscribe(listener)` calls the listener immediately, then when the result changes. For the rows in this store, a maintained result equals a fresh evaluation of the same query after each write. No other tab or server contributes rows unless your application supplies them. ## Write rows `store.write(callback)` collects one batch of row changes. Its callback must be synchronous. The wasm backend applies the batch and updates affected views before the returned promise resolves. The transaction provides three operations: | Operation | Arguments | | --- | --- | | `tx.add(table, row)` | The row to insert | | `tx.edit(table, oldRow, newRow)` | The complete old and replacement rows | | `tx.remove(table, row)` | The complete row to remove | Supply the row's actual old values when editing or removing it. `edit` is not a partial update by primary key. Keep rows immutable so the old row remains available when you build its replacement. These are local engine operations. In a [synced optimistic client](https://rindle.sh/docs/client), use named mutators for server-owned tables instead. The raw write API does not provide authorization or sync. ## Read one row or a page With the store above, `.one()` produces a view whose data is one row or `null`: ```ts const detail = store.query.issue.where.id(1).one().materialize(); console.log(detail.data); // null after the example removes issue 1 detail.destroy(); ``` A cursor starts a result after a known position in the query's order: ```ts const page = store.query.issue .orderBy("id", "asc") .start({ id: 20 }, { exclusive: true }) .limit(20) .materialize(); console.log(page.data); page.destroy(); ``` See [TypeScript queries](https://rindle.sh/docs/supported-queries-ts) for filters, projections, correlated child results, relationships, aggregates, and their restrictions. [Pagination](https://rindle.sh/docs/pagination) explains how cursors and growing limits behave while the underlying rows change. ## Connect a user interface A view works without a framework. In a DOM application, render inside its subscription. In Vue, Svelte, or Solid, copy the current result into the framework's reactive state. Dispose both the listener and view on unmount. For React, pass this same store to the `Rindle` provider from `@rindle/react` and read a query with `useQuery`. The hook manages the view's lifetime. React does not require a server or the optimistic client. Rows use structural sharing: unchanged rows keep their object identity, and changed rows receive new objects. Treat view data as read-only. ## Release resources A listener and a query have separate lifetimes: ```ts unsubscribe(); // stop this callback view.destroy(); // stop maintaining this query ``` Removing a listener leaves the query registered. Destroy every view you create when it is no longer needed. The wasm `Store` has no public `close()` method; drop application references after releasing its views. Each call to `createWasmStore` creates a separate database. The stores share the loaded wasm module, not their tables or rows. ## Initialization and persistence `createWasmStore` calls `initWasm()` for you. To use a custom asset URL, bytes, or a `WebAssembly.Module`, call `await initWasm(moduleOrPath)` before creating the first store. Initialization is shared and runs once. For example, Vite can supply the wasm asset URL explicitly: ```ts import { initWasm } from "@rindle/wasm"; import wasmUrl from "@rindle/wasm/pkg/rindle_bg.wasm?url"; await initWasm(wasmUrl); // before the first createWasmStore call ``` There is no automatic IndexedDB snapshot, durable mutation queue, or cross-tab sync in `createWasmStore`. For a standalone store, your application must load and save its data. The [local-table persistence helper](https://rindle.sh/docs/persisting-local-tables) is part of the optimistic client; it does not attach to an arbitrary wasm store. ## Next steps - [Choose a browser client](https://rindle.sh/docs/browser-clients) — compare local, remote, normalized, and optimistic clients. - [TypeScript queries](https://rindle.sh/docs/supported-queries-ts) — explore supported query shapes. - [Backends and runtimes](https://rindle.sh/docs/backends) — use the Store API over a different engine or transport. - [The browser client](https://rindle.sh/docs/client) — connect to a synced app with optimistic writes. --- [View this page on Rindle](https://rindle.sh/docs/wasm-client) --- # Embed SQLite and live queries Use the database runtime to capture SQL writes and deliver query changes in one process or across read workers. `rindle-replica` embeds a SQLite database with live queries in your Rust process. You create tables, register a query, and write ordinary SQL. The runtime captures the changed rows and emits the changes to each affected query result. Use it for a desktop application, local service, or server that needs derived results to stay current. No daemon, browser, network protocol, or optimistic client is required. Despite its name, this crate can own the authoritative local database. It does not require an upstream database to replicate. ## Choose your entry point | Entry point | Your application owns | Rindle owns | | --- | --- | --- | | `Db` | SQL schema, writes, query lifetime, result consumer | File-backed database, change capture, query engine on one thread | | `Cluster` | The same responsibilities, plus a continuous event drain | One write coordinator and a pool of query workers | | `ClusterConsumer` | A server integration and a nonblocking output sink | Cluster event drain, normalized subscriptions, and progress tracking | | Raw `rindle::graph::Graph` | Sources, row changes, graph and transaction boundaries | Incremental query operators and an optional materialized view | Start with **`Db`** for embedded SQL and live queries. Choose `Cluster` when multiple queries need work across CPU cores. Choose the [raw engine](https://rindle.sh/docs/quickstart) when your application already produces row changes or needs to control sources directly. The crate manifest declares `Apache-2.0` and `publish = false`. The source is in [the Rindle repository](https://github.com/rindle-sh/rindle/tree/main/rust/rindle-replica), whose root contains the license. Use a git dependency or a local checkout. The native Node package has a separate API and [distribution status](https://rindle.sh/docs/backends#node-live-views). ## Add the crates You need a Rust toolchain and a C toolchain for bundled SQLite. In a repository checkout, use the toolchain pinned by `rust/rust-toolchain.toml`. Start a binary crate: ```sh cargo new embedded-demo cd embedded-demo ``` Add these entries to its `Cargo.toml`: ```toml # Cargo.toml [dependencies] rindle = { git = "https://github.com/rindle-sh/rindle" } rindle-replica = { git = "https://github.com/rindle-sh/rindle" } tempfile = "3" # only for this example's temporary directory [patch.crates-io] libsqlite3-sys = { git = "https://github.com/rindle-sh/rindle" } ``` The patch selects Rindle's SQLite build, including its required capture and planner features. Cargo does not inherit patches from a dependency's workspace. For reproducible builds, pin the git entries to the same reviewed revision. ## A complete embedded example Save this program as `src/main.rs`, then run `cargo run`: ```rust // src/main.rs use rindle::value::OwnedValue; use rindle_replica::{Db, QueryId, Update}; fn print_update(update: &Update) { match update { Update::Hydrated { tx_id, changes } => { println!("snapshot at {}: {changes:?}", tx_id.0); } Update::Changed { tx_id, changes } => { println!("changes at {}: {changes:?}", tx_id.0); } Update::PartitionHydrated { .. } => { unreachable!("this example uses an ordinary query, not a query family"); } } } fn main() -> Result<(), Box> { // A real database file, isolated so the example can run repeatedly. let directory = tempfile::tempdir()?; let db = Db::open(directory.path().join("app.db"))?; db.exec_ddl( "CREATE TABLE issues ( id TEXT NOT NULL PRIMARY KEY, title TEXT NOT NULL, open BOOLEAN NOT NULL )", )?; db.register_table("issues")?; let query = db.query( QueryId(1), rindle::table("issues").r#where("open", true).order_by("id", "asc").build(), )?; query.subscribe(print_update); let mut write = db.write()?; write.exec( "INSERT INTO issues VALUES (?, ?, ?)", &[ OwnedValue::str("i1"), OwnedValue::str("first"), OwnedValue::Bool(true), ], )?; let committed = write.commit()?; assert_eq!(db.committed_tx_id(), committed); let mut write = db.write()?; write.exec("UPDATE issues SET open = 0 WHERE id = ?", &[OwnedValue::str("i1")])?; write.commit()?; let mut write = db.write()?; write.exec("DELETE FROM issues WHERE id = ?", &[OwnedValue::str("i1")])?; write.rollback(); // The row remains stored. No query update is delivered. query.destroy(); drop(db); // Close database handles before the temporary directory is removed. Ok(()) } ``` The callback receives an empty snapshot, an addition for `i1`, and a removal when `i1` closes. The rollback emits nothing. Closing the issue removes it from the query result, while the database still contains the row. For a persistent application, pass an application-owned path to `Db::open`. The temporary directory belongs only to this example. The repository also contains a flat-and-nested example: ```sh cd rust cargo run -p rindle-replica --example live_issues ``` ## Opening a replica `Db` opens a file-backed database with separate writer, derivation, and read connections. `:memory:` cannot provide this arrangement and is rejected. A fresh file uses ordinary SQLite WAL. An existing WAL or WAL2 file retains its mode. WAL2 is an explicit option, not the embedded default. `Db` is a cheap `Rc`-backed clone and is **not `Send`**. Keep the handle, its queries, and its write transactions on one owner thread. Drop all handles when that owner closes the database. A separate process or unrelated write connection bypasses change capture, so it cannot write behind the runtime. `Db::open_with(path, OpenOptions { ... })` configures the runtime: | Option | Default | Purpose | | --- | --- | --- | | `plan_queries` | `true` | Plan query joins at registration | | `operator_storage` | `OperatorStorage::Memory` | Keep operator state in memory, or use temporary SQLite spill storage | | `journal` | `JournalMode::Wal` | Choose the database journal mode | | `wal_autocheckpoint` | `None` | Retain SQLite's automatic checkpoint setting, or provide a page threshold | | `foreign_keys` | `ForeignKeys::Enforced` | Enforce foreign-key constraints on application writes | Database persistence and operator storage are separate. SQLite spill files hold scratch state, not a durable query result. Reopening a database requires table registration, query registration, and a new initial snapshot. ## Registering tables Create or migrate tables with `Db::exec_ddl` before registration. Register every application table that you will write or reference from a query. Registration reads its column types and primary key, configures capture, and creates the required source indexes. Repeated registration of the same table is a no-op. The embedded schema has these constraints: - Every table needs a declared primary key. Composite keys are supported, but key cells cannot be `NULL`. - `BLOB` columns, generated columns, and SQL triggers are unsupported. - Numeric integer cells must round-trip through the engine's numeric model. Use text for identifiers that require the full arbitrary signed-64-bit range. - Cascading foreign-key changes pass through capture too. Register the affected tables before a write can change them. A commit that changes an unregistered application table fails. A table does not become registered merely because a SQL statement references it. `exec_ddl` rejects row-changing statements and schema changes to an already registered table. For a schema migration, close the runtime and reopen it, apply the DDL before registration, then recreate queries. The [daemon migration API](https://rindle.sh/docs/schema) manages a different lifecycle. ## Registering a live query `Db::query(QueryId, Ast)` builds and hydrates a query. Choose a distinct `QueryId` for each registration so that later snapshot reads identify it unambiguously. Query construction can fail because a table is unregistered or a query shape is unsupported. `Db::query_json` accepts the serialized AST instead. The builder supports nested relationships through explicit correlation: ```rust let with_comments = db.query( QueryId(2), rindle::table("issues") .sub_as("comments", |issue| { rindle::table("comments").r#where("issue_id", issue.col("id")) }) .build(), )?; ``` This example requires a registered `comments` table. See [query shapes](https://rindle.sh/docs/supported-queries) for filters, ordering, aggregates, and supported correlations. Each registration creates its own pipeline. Identical ASTs or identifiers do not share work automatically at the `Db` layer. ## Subscribing to changes Subscribe immediately after query registration, before further writes. `Query::subscribe` first supplies the snapshot cached at registration. It then calls the subscriber during commits that affect the query. A subscriber added later does not receive the intervening history. For a new independent consumer, register a fresh query and subscribe to it. | Update | Consumer action | | --- | --- | | `Hydrated { tx_id, changes }` | Replace the previous result with these initial `Add` events | | `Changed { tx_id, changes }` | Apply the changes in order to the existing result | | `PartitionHydrated { tx_id, binding, changes }` | Replace one query-family partition; ordinary `Db::query` does not use this variant | `Hydrated` can recur after the runtime rebuilds an oversized derivation. Treat it as replacement state every time. [Fold the delta stream](https://rindle.sh/docs/example-rust) shows a consumer that implements this rule. Callbacks run synchronously during `Db` commit, after the data commits. Keep callbacks short and avoid re-entering the database from them. Do not panic in a callback: the data is already committed, and a callback panic is not a rollback. `Query::destroy(self)` stops delivery and reclaims the pipeline. Dropping a `Query` alone does not stop it. Callbacks remain registered until query teardown. ## Change events `ChangeEvent` is an alias for `rindle::CaughtChange`. `NodeData` is an alias for `rindle::CaughtNode`. | Event | Meaning | | --- | --- | | `Add(node)` | A row and its nested results entered the query result | | `Remove(node)` | A row and its nested results left the query result | | `Edit { old, row }` | A result row changed | | `Child { row, rel, change }` | A nested result changed beneath a parent row | Rows use positional cells in the result schema. `Add` and `Remove` include relationship trees. `Edit` includes only the old and new rows. `Child` identifies a relationship by its slot, not its string name. The [change model](https://rindle.sh/docs/change-model) defines the full payloads. ## The write path `Db::write()` opens the controlled writer transaction. Only one write transaction can be open on a handle at a time. `exec` runs a parameterized statement, and `exec_batch` runs statements without parameters. Keep transaction control in the Rust API: do not send manual `BEGIN`, `COMMIT`, or `ROLLBACK` statements. `commit(self)` consumes the transaction and returns its durable `TxId`. For `Db`, the sequence is: 1. Capture row changes while the SQL statements run. 2. Pin the pre-commit database snapshot on the derivation connection. 3. Apply the captured rows through a read-only batch overlay to compute query deltas. 4. Commit the database changes and transaction watermark together. 5. Deliver query updates synchronously before returning. The derivation connection does not replay SQL writes. The overlay supplies the changing rows while SQLite supplies the stable pre-commit data. `rollback(self)` or dropping an uncommitted transaction discards its writes and delivers no query updates. Ordinary capture or derivation errors abort the transaction. If the derivation exceeds its memory budget, the runtime can commit and rebuild the queries instead. `commit_with_info()` reports this case through `CommitInfo::shed`, and subscribers receive replacement snapshots. A failed query rebuild removes that query from the registry. `Db::committed_tx_id()` reports the last committed watermark. It does not imply that an external consumer finished processing an event. ### SQL reads and current snapshots `Db::read` supplies a physically read-only SQLite connection for ordinary SQL reads of committed data. It does not see uncommitted writes from a `WriteTxn`. For a read inside that transaction, bring the `MutationSql` trait into scope: ```rust use rindle_replica::MutationSql; let mut write = db.write()?; let rows = write.query("SELECT id FROM issues WHERE open = ?", &[OwnedValue::Bool(true)])?; write.rollback(); ``` `Db::read_snapshot(query_id)` assembles the registered query's current result as `Add` events without draining its change stream. This operation still traverses the result to assemble it. It is not a pointer to a cached array. ## Scale out readers `Cluster` distributes queries across worker threads. One query lives on one worker, so adding workers does not split a single expensive query across cores. The coordinator remains `!Send` and has one controlled writer. Unlike `Db`, a cluster delivers events asynchronously. It can send `Changed` slices **before the transaction commits**. Receiving one slice does not make its rows committed, and one transaction can produce several slices for one query. A raw result consumer must: 1. Record the worker returned by `Cluster::query` for each query. 2. Stage `Changed` slices by query and transaction, preserving arrival order. 3. Release a query's staged changes when its worker reports `Progressed` through that transaction. 4. For a result spanning several workers, wait for every relevant worker before releasing the transaction. 5. On `Faulted`, discard that query's staged changes and register a new query to obtain a replacement snapshot. `Progressed` follows all `Changed` slices for that worker and transaction, after a successful commit. A rollback after streaming can therefore produce a terminal query fault, even though no database write commits. The output channel is bounded. Start a continuous drain before queries or writes, and keep it alive until the cluster shuts down. This lifecycle example logs the protocol events; it does not publish a committed result collection: ```rust // examples/cluster.rs use rindle_replica::{Cluster, ClusterEvent, QueryId}; fn run_cluster(path: &std::path::Path) -> Result<(), Box> { let (cluster, events) = Cluster::open(path, 2)?; let drain = std::thread::spawn(move || { for event in events { match event { ClusterEvent::Update { query_id, update } => { println!("query {} received (changes may be provisional): {update:?}", query_id.0); } ClusterEvent::Progressed { worker, tx_id } => { println!("worker {worker} committed through transaction {}", tx_id.0); } ClusterEvent::Faulted { query_id, reason, .. } => { eprintln!("query {} stopped: {reason}", query_id.0); } } } }); cluster.exec_ddl("CREATE TABLE issues (id TEXT NOT NULL PRIMARY KEY, open BOOLEAN NOT NULL)")?; cluster.register_table("issues")?; let worker = cluster.query(QueryId(1), rindle::table("issues").r#where("open", true).build())?; println!("query 1 runs on worker {worker}"); let mut write = cluster.write()?; write.exec("INSERT INTO issues VALUES ('i1', 1)", &[])?; write.commit()?; cluster.sync(); cluster.destroy_query(QueryId(1)); drop(cluster); drain.join().expect("event drain completed"); Ok(()) } ``` Call this function with a fresh database path. The print statements are a demo consumer. A production drain must not wait on a slow subscriber or block the coordinator. A write-then-drain sequence can deadlock when the bounded channel fills. `Cluster::query` returns the worker index, not a `Query` handle. Track progress for the relevant workers through `Progressed` events. A successful cluster commit means the database committed; it does not mean all query updates reached the consumer. `Faulted` is terminal for a query. Re-register it and replace its result with the new snapshot if the application still needs it. For normalized server subscriptions, `ClusterConsumer` owns the drain thread and routes output to a `DrainSink`. Its `batch` callback is also eager. A downstream consumer releases those batches according to `progress` frames, whose `cv_min` covers the connection's subscribed workers. The [daemon](https://rindle.sh/docs/daemon) uses that server-oriented layer. Embedded applications that need raw result events can use `Cluster` directly with the drain contract described here. ## Errors All fallible operations return `ReplicaError`. Relevant variants include: | Variant | Cause | | --- | --- | | `Sqlite { op, source }` | A SQLite operation failed | | `Open` | Invalid database configuration or operation lifecycle | | `NotThreadsafe` | The SQLite build lacks thread safety | | `Schema` | Missing or unsupported table shape | | `UnsupportedColumnType` | A column type cannot enter the engine | | `Capture` | A write cannot produce valid captured row changes | | `Build` | A query cannot be constructed | | `Rindle` | An engine operation failed | | `Closed` | A required runtime or worker is unavailable | The supported query and schema limits apply to embedded callers too. The runtime does not turn arbitrary SQL expressions into maintained queries. ## Next steps - [Fold the delta stream yourself](https://rindle.sh/docs/example-rust) — maintain an application-owned result from `Db` updates. - [Raw engine quickstart](https://rindle.sh/docs/quickstart) — supply sources and changes directly. - [How it works](https://rindle.sh/docs/how-it-works) — understand sources, operators, and views. - [Crates and API map](https://rindle.sh/docs/crates) — locate the relevant public APIs. - [Run the daemon](https://rindle.sh/docs/daemon) — expose the runtime through network protocols. - [Deploying and scaling](https://rindle.sh/docs/deploy) — choose a hosted topology when the application needs one. --- [View this page on Rindle](https://rindle.sh/docs/replica-and-views) --- # Backends & runtimes Choose where queries run: a browser, Node, a remote server, or an embedded Rust application. `@rindle/client` defines TypeScript schemas, queries, `Store`, and materialized views. It does not choose a database or network connection. A **backend** connects that API to an engine or a stream of server results. This page maps those implementations and explains custom compositions. For a browser application, first [choose a browser client](https://rindle.sh/docs/browser-clients). The same view API can have different data, write, and lifecycle guarantees. ## Choose a runtime | Runtime or composition | Entry point | Where queries run | | --- | --- | --- | | Standalone browser | [`createWasmStore`](https://rindle.sh/docs/wasm-client) from `@rindle/wasm` | One in-memory wasm database | | Native Node | [`createReplicaStore`](#node-live-views) from `@rindle/replica` | One temporary native SQLite database | | Remote results | [`createRemoteStore`](#remote-result-client) from `@rindle/remote` | A compatible flat-protocol server | | Normalized browser sync | [`createNormalizedStore`](#normalized-client) from `@rindle/normalized` | Local wasm over rows supplied by a normalized source | | Synced optimistic app | [`createRindleClient`](https://rindle.sh/docs/client) from `@rindle/optimistic` | Local wasm with predictions and server query subscriptions | | Custom optimistic integration | [`createOptimisticStore`](#custom-optimistic-composition) from `@rindle/optimistic` | Local wasm with a caller-supplied authoritative source | | Server rendering | [`createServerStore`](https://rindle.sh/docs/ssr) from `@rindle/client` | One-shot server reads or supplied seeds | | Embedded Rust | [`rindle` and `rindle-sqlite`](https://rindle.sh/docs/quickstart) | Sources and query graphs controlled by your program | | Durable Rust database | [`rindle_replica::Db`](https://rindle.sh/docs/replica-and-views) | SQLite with live-query maintenance around SQL writes | [SQL over HTTP](https://rindle.sh/docs/sql-client) is a separate access method. It returns rows for individual statements and does not create a `Store` or live view. Native mobile bindings are another integration. Apple packaging lives in `swift/RindleMobile` in the repository. Kotlin query bindings exist, but Android runtime packaging remains incomplete. These bindings do not use the TypeScript `Backend` interface. ## Node live views `createReplicaStore(schema)` creates a synchronous TypeScript store backed by native SQLite. It accepts the same table definitions and query builder as the wasm store. It does not require a daemon or an API server. The native package is currently a repository integration. The standard release workflow does not publish it, so `pnpm add @rindle/replica` is not a supported public installation path yet. To run this example, use a Rindle checkout with Node 22.18 or newer, the repository's Rust toolchain, and a C toolchain. From the repository root, install dependencies and build the native addon: ```sh pnpm install pnpm run build:native ``` Save this program as `rust/rindle-replica-node/native-example.ts`: ```ts import { boolean, createReplicaStore, createSchema, number, string, table, } from "@rindle/replica"; const issue = table("issue") .columns({ id: number(), title: string(), open: boolean() }) .primaryKey("id"); const schema = createSchema({ tables: [issue] }); const store = createReplicaStore(schema); const view = store.query.issue .where.open(true) .orderBy("id", "asc") .materialize(); const unsubscribe = view.subscribe((rows) => console.log(rows)); const first = { id: 1, title: "Ship it", open: true }; await store.write((tx) => tx.add("issue", first)); await store.write((tx) => tx.edit("issue", first, { ...first, open: false })); unsubscribe(); view.destroy(); ``` Run it from the repository root: ```sh node --conditions=@rindle/source rust/rindle-replica-node/native-example.ts ``` The listener prints an empty array, the new open issue, and an empty array after the issue closes. Each write updates the view before its promise resolves. `unsubscribe()` removes the listener. `view.destroy()` unregisters the query. Each store creates a fresh temporary database. Its API accepts no database path, and it has no explicit `close()` method. The native handle releases its database when garbage collection drops it. Do not use this store for data that must survive a process restart. For a durable Rust database, use [`rindle_replica::Db::open(path)`](https://rindle.sh/docs/replica-and-views). For durable SQL access from Node, use a Rindle deployment and [`@rindle/sql-client`](https://rindle.sh/docs/sql-client). The [native implementation](https://github.com/rindle-sh/rindle/blob/main/rust/rindle-replica-node/src/replica_core.rs) defines the temporary database lifecycle. ## Remote result client `createRemoteStore(schema, urlOrTransport, options?)` creates a store backed by flat result changes. It has no wasm engine or shared local base tables. A materialized query displays what the remote server sends. This requires a server that implements Rindle's **flat** `hello`/`batch` protocol. Production `rindled` serves normalized `nhello`/`nbatch` frames instead, so this constructor does not connect to the standard app deployment. Supplying an API lease does not change its result protocol. The repository's private reference package implements `createReplicaServer` for this flat path and `createNormalizedServer` for the normalized path below. It is not a published production API server. The [flat integration test](https://github.com/rindle-sh/rindle/blob/main/packages/reference/server/test/e2e.mjs) shows both ends, including writes and recovery after a missing batch. For a custom server, define a schema and a named query shared with that server. This small example uses numeric issue IDs: ```ts // remote-example.ts import { createSchema, defineQuery, newQueryBuilder, number, string, table, } from "@rindle/client"; const issue = table("issue") .columns({ id: number(), title: string() }) .primaryKey("id"); export const schema = createSchema({ tables: [issue] }); const q = newQueryBuilder(schema); export const issuesQuery = defineQuery("issues", () => q.issue.orderBy("id", "asc")); ``` This client function accepts an already configured transport for that server: ```ts import { createRemoteStore } from "@rindle/remote"; import type { Transport } from "@rindle/remote"; import { issuesQuery, schema } from "./remote-example.ts"; export function openRemoteIssues(transport: Transport) { const store = createRemoteStore(schema, transport); const view = store.materialize(issuesQuery()); const unsubscribe = view.subscribe((rows) => console.log(rows)); return { store, view, close() { unsubscribe(); view.destroy(); transport.close(); }, }; } ``` A WebSocket implementation is available as `new WsTransport(url)`. Keep your own transport reference if you need to close it. A transport has one message handler; do not share one instance between independent backends or sources. The backend sends the query's name and arguments. It does not send the local builder AST. The custom server must register and authorize that name. A bare `store.query.issue.materialize()` has no remote identity and cannot subscribe. `store.write` forwards raw row mutations. By default, it sends a WebSocket `mutate` message; `sendMutation` can replace that sender. Its promise represents the sender's completion, not receipt of the updated view. There is no optimistic prediction or authoritative acknowledgment protocol in this store. The flat backend does not expose the `ResultType` loading lifecycle. Its views report `complete` through the core's default, even before the first snapshot. Do not use that value, or `store.run`, to wait for a remote result on this backend. A custom integration must expose its own readiness and error handling. ## Normalized client `createNormalizedStore(schema, source)` adds a local wasm engine to a `NormalizedSource`. A normalized stream supplies table rows and selected columns needed by server queries. The client shares them across active subscriptions and maintains local views over them. Initialize wasm before calling this synchronous constructor. For the shared schema and named query above, the following function connects to a **custom normalized** transport: ```ts import { createNormalizedStore } from "@rindle/normalized"; import { createRemoteNormalizedSource } from "@rindle/remote"; import type { Transport } from "@rindle/remote"; import { initWasm } from "@rindle/wasm"; import { issuesQuery, schema } from "./remote-example.ts"; export async function openNormalizedIssues(transport: Transport) { await initWasm(); const source = createRemoteNormalizedSource(transport); const store = createNormalizedStore(schema, source); const view = store.materialize(issuesQuery()); const unsubscribe = view.subscribe((rows) => { console.log(view.resultType, rows); }); return { store, view, close() { unsubscribe(); view.destroy(); transport.close(); }, }; } ``` For the reference server pairing, see the [normalized integration test](https://github.com/rindle-sh/rindle/blob/main/packages/reference/server/test/normalized.e2e.mjs). You can also implement `NormalizedSource` directly for an in-process transport. The source's default subscription uses `{ name, args }`. Production `rindled` requires a lease token for application queries. `resolveSubscribe` can obtain that token from your API server and return `{ leaseToken }`. That supplies the subscription credential; it does not configure the complete connection. For production lease integration, your code must also choose the returned WebSocket endpoint, supply required affinity tickets, and handle reconnection and endpoint changes. Returning `wsEndpoint` from this source's resolver does not migrate its transport. `WsTransport` reconnects a socket, but `RemoteNormalizedSource` does not rebuild active subscriptions after a socket reconnect. Rebuild the source and views, or implement that lifecycle in your own source. `createRindleClient` provides the integrated app behavior. A named view starts with `resultType === "unknown"` and becomes `complete` after its server snapshot. A bare `store.query` view reads available local rows and opens no server subscription. It cannot prove that missing local rows are absent from the server. Releasing a server query releases its retained rows and columns. Data remains only while another server subscription also retains it. A local view does not by itself retain those server rows. `store.write` forwards raw mutations through `source.mutate`; it does not predict them. `RemoteNormalizedSource` has the same optional `sendMutation` adapter as the flat backend. The production daemon ignores raw WebSocket writes, and the standard app mutation route expects named envelopes. Use your own authoritative write endpoint or adapter. This backend does not implement `store.writeLocal` or local-table persistence. ## Custom optimistic composition `createOptimisticStore(schema, source, registry, options?)` exposes the prediction and rebase engine without constructing the standard app connections. Call `await initWasm()` first. The result contains `{ store, backend, mutate }`. An `OptimisticSource` supplies normalized data, mutation progress, and rejection signals. It also sends named mutation envelopes to the authority. The backend uses those signals to retire confirmed predictions and replay pending mutators. `RemoteOptimisticSource` from `@rindle/remote` implements this source over a transport. Its options support lease resolution and a named mutation sender. Unlike the plain normalized source, it integrates transport reconnect signals with subscription recovery. It also supports a transport factory for endpoint changes. If you construct these pieces yourself, you own HTTP mutation delivery, ordering and retries, client identity, authentication, connection setup, and cleanup. Use [`createRindleClient`](https://rindle.sh/docs/client) when your server follows the standard Rindle app contract. It composes those pieces and initializes wasm for you. ## What a backend implements The required `Backend` methods are: ```ts interface Backend { registerQuery(qid: QueryId, ast: unknown, remote?: RemoteQuery): void; unregisterQuery(qid: QueryId): void; mutate(mutations: Mutation[]): Promise; onEvent(handler: (qid: QueryId, ev: ChangeEvent) => void): void; } ``` `Backend`, `QueryId`, `RemoteQuery`, `Mutation`, and `ChangeEvent` are exported from `@rindle/client`. The excerpt shows the required contract; optional methods add readiness, local writes, remote-query retention, and commit boundaries. A backend emits `hello` to describe the result schema, `snapshot` for initial rows, and `batch` for later changes. The `Store` applies these events to a view. For a normalized or optimistic backend, these are the **local engine's** result events, computed from the upstream normalized rows. A `Store` owns the backend's event and readiness handlers. Do not replace them with direct `backend.onEvent` or `backend.onResultType` calls after constructing the store. Use view subscriptions, `store.subscribeChanges`, or `store.subscribeResultType` for application observers. ## Timing and lifetime Local wasm and native backends hydrate before `materialize()` returns. A remote subscription must receive its initial server data first. A query's current browser rows can lag the authoritative server. Remote sources check stream order and can request a new snapshot after a gap. That protocol recovery differs from reconnecting a closed socket. Check the specific source's reconnect support before relying on it. Remove listeners and destroy manually created views when their owner closes. For network compositions, also close the transport or source you own. The integrated app client has its own `close()` method. The underlying `Store` interface has no universal shutdown method. The engine's correctness contract applies to its current input: its maintained view equals a fresh evaluation of the same query over that input. It does not make a partial client replica complete or a disconnected client current. --- [View this page on Rindle](https://rindle.sh/docs/backends) --- # Raw Rust engine quickstart Connect a SQLite source to the raw Rust engine, supply row changes, and observe the maintained result. This tutorial uses the raw Rust engine over a SQLite source. Your program supplies row changes and reads the maintained result. It is useful when you need to control sources or integrate an existing stream of changes. For an embedded database that captures **ordinary SQL writes** automatically, start with [`rindle-replica::Db`](https://rindle.sh/docs/replica-and-views). It manages the database, change capture, query registration, and commit boundaries. The raw engine is a separate integration choice, not a prerequisite for that runtime. ## Add the crates The example needs Rust and a C toolchain for bundled SQLite. The crates are available from the repository and currently have `publish = false`. ```sh cargo new raw-engine-demo cd raw-engine-demo ``` Add these entries to `Cargo.toml`: ```toml # Cargo.toml [dependencies] rindle = { git = "https://github.com/rindle-sh/rindle" } rindle-sqlite = { git = "https://github.com/rindle-sh/rindle" } rusqlite = { version = "0.32", features = ["bundled"] } [patch.crates-io] libsqlite3-sys = { git = "https://github.com/rindle-sh/rindle" } ``` The patch selects Rindle's SQLite build. Cargo does not inherit patches from a dependency's workspace. Pin the git entries to one reviewed revision for a reproducible application build. `rindle` and `rindle-sqlite` declare Apache-2.0 in their manifests. The [crate map](https://rindle.sh/docs/crates) explains package ownership and distribution. ## Run a complete live query Save this program as `src/main.rs`, then run `cargo run`: ```rust // src/main.rs use std::collections::HashMap; use std::rc::Rc; use rindle::change::SourceChange; use rindle::graph::Graph; use rindle::value::{owned_row, OwnedValue, SourceSchema, ValueType}; use rindle::{build_pipeline, table, view_schema}; use rindle_sqlite::{ColumnDef, GraphTableSourceExt, TableSource}; use rusqlite::Connection; fn main() -> Result<(), Box> { let database = Rc::new(Connection::open_in_memory()?); database.execute_batch( "CREATE TABLE issues ( id TEXT NOT NULL PRIMARY KEY, title TEXT NOT NULL, open BOOLEAN NOT NULL )", )?; let columns = vec![ ColumnDef { name: "id".into(), ty: ValueType::String, optional: false }, ColumnDef { name: "title".into(), ty: ValueType::String, optional: false }, ColumnDef { name: "open".into(), ty: ValueType::Boolean, optional: false }, ]; let schema = SourceSchema::new( vec!["id", "title", "open"], vec![0], // primary-key column vec![(0, true)], // default order: id ascending ); let mut graph = Graph::new(); let source = graph.add_table_source(TableSource::try_new_with_schema( database.clone(), "issues", columns, vec![0], schema.clone(), )?); let sources = HashMap::from([("issues", (source, schema))]); let resolve = |name: &str| sources.get(name).cloned(); let ast = table("issues").r#where("open", true).build(); let top = build_pipeline(&mut graph, &ast, &resolve)?; let view = graph.add_view(top, view_schema(&ast, &resolve)?); graph.set_sink_edge(top, view); graph.try_hydrate(view)?; assert!(graph.view_data(view).items.is_empty()); let open = owned_row(vec![ OwnedValue::str("i1"), OwnedValue::str("first"), OwnedValue::Bool(true), ]); graph.try_source_push(source, SourceChange::Add(open.clone()))?; graph.flush_view(view); assert_eq!(graph.view_data(view).items.len(), 1); let closed = owned_row(vec![ OwnedValue::str("i1"), OwnedValue::str("first"), OwnedValue::Bool(false), ]); graph.try_source_push(source, SourceChange::Edit { old: open, row: closed })?; graph.flush_view(view); assert!(graph.view_data(view).items.is_empty()); // The source wrote SQLite as well as updating the view. let stored: i64 = database.query_row("SELECT count(*) FROM issues", [], |row| row.get(0))?; assert_eq!(stored, 1); println!("The closed issue remains stored and leaves the open-issues view."); Ok(()) } ``` The first push inserts an issue into SQLite and the view. The second push closes it, so the view removes it. The query result stays equal to a fresh query over the current source data. For the corresponding repository example: ```sh cd rust cargo run -p rindle-sqlite --example live_query ``` ## What the program owns A **source** supplies rows for one table. `SourceSchema` describes the column positions, primary key, and default order. Its column order must match the `ColumnDef` list and every row you push. `TableSource` needs a unique key for row lookup. This example's declared text primary key creates the required SQLite index. A single `INTEGER PRIMARY KEY` can use SQLite's rowid index instead. A **query AST** describes the result. `build_pipeline` converts it into graph operators and returns the top operator's `NodeId`. It does not create a view. The `resolve` closure supplies each table's source and schema. A **view** stores the result as `ViewData`. `view_schema` derives its result schema, `add_view` creates it, and `set_sink_edge` connects it to the pipeline. Hydration fetches the initial result. Later source pushes update that view. The program owns the graph's lifetime. Dropping the graph releases its sources, pipelines, views, and operator state. The example contains one query, so no separate query registry or teardown layer is needed. ## Writes and batch boundaries `SourceChange` has `Add`, `Remove`, and `Edit { old, row }` variants. They carry complete positional rows. The SQLite source writes those changes through to the database. The memory source applies them to its in-process table. Once a SQLite source is active, direct SQL writes on its connection bypass the graph and can leave the view stale. To keep SQL as your write API, use the [replica runtime](https://rindle.sh/docs/replica-and-views). `flush_view` publishes a batch to view listeners. It does not commit SQLite, provide rollback, or make several source pushes atomic. This example uses SQLite autocommit. Use the runtime when you need SQL transaction boundaries and post-commit delivery. Fallible `try_*` methods report source and engine errors. They do not turn the raw graph into a transaction manager. The graph is `!Send`; keep it on one thread and use independent graphs for work on other threads. ## Extend the query The same builder supports projection, relationships, filters, and aggregates. For example, a nested query requires both `issues` and `comments` sources: ```rust let ast = table("issues") .sub_as("comments", |issue| { table("comments").r#where("issue_id", issue.col("id")) }) .build(); ``` The relationship belongs to the query. It is not a relation declaration in `SourceSchema`. Register every source before constructing this pipeline. ## Next steps - [Embedded SQLite and live queries](https://rindle.sh/docs/replica-and-views) — use SQL writes with automatic change capture. - [How it works](https://rindle.sh/docs/how-it-works) — understand the source, graph, and view lifecycle. - [Fold the delta stream](https://rindle.sh/docs/example-rust) — use an application-owned result instead of `ViewData`. - [Change model](https://rindle.sh/docs/change-model) — understand input changes and result deltas. - [Supported queries](https://rindle.sh/docs/supported-queries) — choose query shapes and correlations. --- [View this page on Rindle](https://rindle.sh/docs/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) --- # How it works Follow the Rust engine lifecycle: create sources, build a query pipeline, hydrate a view, and apply changes. Rindle maintains a query result from changes to its source rows. It evaluates the query to create an initial result, then updates the affected operators after a write. The correctness contract is **view-after-write == fresh-query** over the same data. Incremental does not mean constant work. One source change can affect many result rows, and operators can fetch supporting rows through indexes. Initial hydration, recovery, and full result serialization also have costs. This page explains the engine beneath Rindle's APIs. You can use the [embedded database runtime](https://rindle.sh/docs/replica-and-views) or [browser store](https://rindle.sh/docs/wasm-client) without managing a graph yourself. ## The data path | Stage | Meaning | Raw Rust entry point | | --- | --- | --- | | Register sources | Supply base rows and table schemas | `Graph::try_add_source` or SQLite `TableSource` | | Describe a query | Build its filters, order, projection, and relationships | `rindle::table(...).build()` | | Build the pipeline | Resolve tables and create connected operators | `rindle::build_pipeline` | | Hydrate a consumer | Fetch the initial result | A `View` or a change sink | | Apply changes | Update affected operators and publish result changes | `Graph::try_source_push` and consumer-specific delivery | An **AST** is the query description. A **graph** stores the operators that execute it. A **source** supplies rows for a table. A **view** stores the result, while a **change sink** gives the result deltas to your own consumer. ## 1. Build a query The fluent builder produces a `rindle::Ast`: ```rust let ast = rindle::table("issues") .r#where("open", true) .order_by("id", "asc") .build(); ``` `r#where` uses a raw identifier because `where` is a Rust keyword. With the `serde` feature, an AST can also come from the JSON wire representation. The Rust and TypeScript query builders target that common representation. A builder query differs from a SQL request. Its supported operators are listed in [query shapes](https://rindle.sh/docs/supported-queries). The [SQL client](https://rindle.sh/docs/sql-client) runs ordinary statements and does not automatically maintain their results. ## 2. Lower it into a graph `build_pipeline` resolves every named table through a closure and creates the operators needed by the AST. The closure returns the source's `NodeId` and `SourceSchema`. The public types live at these paths: ```rust use rindle::graph::{Graph, NodeId}; use rindle::value::SourceSchema; use rindle::{build_pipeline, Ast, BuildError}; ``` `build_pipeline(&mut graph, &ast, &resolve)` returns `Result`. The returned node is the pipeline's top operator. A materialized view or change sink must still be attached to it. The core engine can use in-memory sources without SQLite or a C toolchain. `rindle-sqlite` supplies the SQLite source implementation. The [raw quickstart](https://rindle.sh/docs/quickstart) shows complete source registration and pipeline construction. ## 3. Hydrate a view The built-in `View` materializes rows and their nested relationships. Its schema includes result order and relationship slots. `view_schema(&ast, &resolve)` derives that schema from the query and source schemas. ```rust let top = rindle::build_pipeline(&mut graph, &ast, &resolve)?; let view = graph.add_view(top, rindle::view_schema(&ast, &resolve)?); graph.set_sink_edge(top, view); graph.try_hydrate(view)?; let data = graph.view_data(view); ``` `ViewData::items` contains entries. Each entry carries its row and nested relationship results. Rows use positional cells, read through `row.col(index)`. For a consumer that owns its own result collection, attach a change sink instead. Hydration then supplies initial `CaughtChange::Add` events. See [the fold example](https://rindle.sh/docs/example-rust#attach-a-raw-change-sink). ## 4. Push changes A source push describes one base-table mutation: ```rust use rindle::change::SourceChange; use rindle::value::{owned_row, OwnedValue}; let row = owned_row(vec![OwnedValue::str("i1"), OwnedValue::Bool(true)]); graph.try_source_push(source, SourceChange::Add(row))?; graph.flush_view(view); ``` This fragment assumes a source whose columns are `id` and `open`, in that order. An edit supplies both the complete previous row and its replacement. The engine propagates the resulting changes through filters, joins, ordering, and aggregates. `flush_view` notifies view listeners. It is not a database commit. With a raw change sink, `take_sink_changes` drains the accumulated deltas instead. The [change model](https://rindle.sh/docs/change-model) distinguishes input changes from result changes. Use the fallible `try_*` methods to receive `RindleError` values. A `Graph` is `!Send`, so it stays on one thread. The replica runtime scales through independent worker graphs and message passing. ## Query planner A correlated `EXISTS` can sometimes run from either the parent side or the child side. The cheaper direction depends on the data and available indexes. `rindle-planner` chooses a result-equivalent plan before pipeline construction. Its public entry point is `rindle_planner::plan_ast`. It takes an AST and an `Rc`, then returns a planned AST. The SQLite cost model is `rindle_sqlite::SqliteCostModel`. Planning changes the work, not the query result. The [replica runtime](https://rindle.sh/docs/replica-and-views#opening-a-replica) enables planning by default and keeps that plan for the registration's lifetime. Raw graph callers choose whether to run the planner. ## Driving it from a database `rindle-replica` connects this engine to a controlled SQLite writer. Its preupdate hook captures row changes while SQL runs. A separate connection reads the pre-commit snapshot, and a batch overlay makes earlier changes in that transaction visible to later derivation steps. The derivation connection is read-only. It does not replay the SQL writes. `Db` delivers callbacks synchronously after the database commits. `Cluster` can stream provisional changes before commit. Each worker sends a `Progressed` marker after its transaction commits and all its changes are sent. A consumer must stage those changes until the relevant workers progress. It must discard provisional state for a faulted query and register that query again. The [cluster guide](https://rindle.sh/docs/replica-and-views#scale-out-readers) explains this delivery contract and the continuous event drain. A query remains an in-process object until an application adds transport. The [daemon](https://rindle.sh/docs/daemon) and [synced app](https://rindle.sh/docs/architecture) build network and authorization layers above the runtime. They are optional for embedded users. ## Next steps - [Embedded SQLite and live queries](https://rindle.sh/docs/replica-and-views) — start with SQL writes and automatic capture. - [Raw Rust quickstart](https://rindle.sh/docs/quickstart) — own the graph and sources directly. - [Fold the delta stream](https://rindle.sh/docs/example-rust) — maintain an application-owned result. - [Change model](https://rindle.sh/docs/change-model) — inspect the exact event payloads. - [Supported queries](https://rindle.sh/docs/supported-queries) — choose a query shape. - [Crates](https://rindle.sh/docs/crates) — find each public type and runtime. --- [View this page on Rindle](https://rindle.sh/docs/how-it-works) --- # 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) --- # Fold the delta stream yourself (Rust) Consume individual view changes in Rust and assemble a result that matches a fresh query. A live query supplies a stream of result changes. Your application can apply those changes to its own collection instead of issuing a fresh SQL query after every write. This example uses [`rindle-replica::Db`](https://rindle.sh/docs/replica-and-views), which captures changes from ordinary SQL writes. The consumer owns a map of open issues. It handles the initial snapshot, later deltas, replacement snapshots, and query cleanup. A final SQL read checks that the maintained map is correct. For the raw `Graph` equivalent, see [Attach a raw change sink](#attach-a-raw-change-sink). Both entry points deliver the same `CaughtChange` payloads. ## A complete consumer Use the dependencies from the [embedded setup](https://rindle.sh/docs/replica-and-views#add-the-crates), then save this program as `src/main.rs` and run `cargo run`: ```rust // src/main.rs use std::cell::RefCell; use std::collections::BTreeMap; use std::rc::Rc; use rindle::value::{OwnedRow, OwnedValue, Value}; use rindle_replica::{ChangeEvent, Db, QueryId, Update}; fn text(row: &OwnedRow, column: usize) -> String { match row.col(column) { Value::Str(bytes) => std::str::from_utf8(bytes).expect("valid UTF-8").to_owned(), _ => panic!("this query projects a non-null text column"), } } fn apply(view: &mut BTreeMap, update: &Update) { let changes = match update { Update::Hydrated { changes, .. } => { view.clear(); // A hydration replaces previous state, including after recovery. changes } Update::Changed { changes, .. } => changes, Update::PartitionHydrated { .. } => { unreachable!("this example does not register a query family"); } }; for change in changes { match change { ChangeEvent::Add(node) => { view.insert(text(&node.row, 0), text(&node.row, 1)); } ChangeEvent::Remove(node) => { view.remove(&text(&node.row, 0)); } ChangeEvent::Edit { old, row } => { view.remove(&text(old, 0)); view.insert(text(row, 0), text(row, 1)); } ChangeEvent::Child { .. } => { unreachable!("this query has no nested relationships"); } } } } fn main() -> Result<(), Box> { let directory = tempfile::tempdir()?; let db = Db::open(directory.path().join("app.db"))?; db.exec_ddl( "CREATE TABLE issues ( id TEXT NOT NULL PRIMARY KEY, title TEXT NOT NULL, open BOOLEAN NOT NULL )", )?; db.register_table("issues")?; let view = Rc::new(RefCell::new(BTreeMap::::new())); let query = db.query( QueryId(1), rindle::table("issues") .r#where("open", true) .select("id") .select("title") .order_by("id", "asc") .build(), )?; let subscriber_view = view.clone(); query.subscribe(move |update| apply(&mut subscriber_view.borrow_mut(), update)); let mut write = db.write()?; write.exec("INSERT INTO issues VALUES ('i1', 'first', 1), ('i2', 'second', 1)", &[])?; write.commit()?; let mut write = db.write()?; write.exec( "UPDATE issues SET title = ? WHERE id = 'i1'", &[OwnedValue::str("renamed")], )?; write.exec("UPDATE issues SET open = 0 WHERE id = 'i2'", &[])?; write.commit()?; let mut write = db.write()?; write.exec("DELETE FROM issues WHERE id = 'i1'", &[])?; write.rollback(); // The consumer still contains i1. // A fresh read verifies the result. It does not maintain the consumer. let fresh = db.read(|connection| { let mut statement = connection.prepare( "SELECT id, title FROM issues WHERE open = 1 ORDER BY id", )?; let rows = statement.query_map([], |row| { Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) })?; rows.collect::, _>>() })?; assert_eq!(*view.borrow(), fresh); assert_eq!(view.borrow().get("i1").map(String::as_str), Some("renamed")); assert_eq!(view.borrow().len(), 1); query.destroy(); drop(db); println!("The maintained view equals the fresh SQL result."); Ok(()) } ``` The first commit adds two entries. The second edits `i1` and removes `i2` from the open-issues result. The rolled-back delete produces no event. ## What the consumer must know **Row identity and column order.** This table declares `id` first and `title` second, so the consumer reads columns `0` and `1`. Those positions come from the schema, not the order of method calls in the query builder. `OwnedRow::col` returns a borrowed `Value`. **Snapshot versus delta.** A `Hydrated` update is a complete replacement. Clearing the map first matters after recovery: an old row can be absent from the new snapshot without a separate `Remove` event. `Changed` updates apply to the existing state in their delivered order. **Query shape.** A map ordered by primary key fits this flat query. A query with a different sort order needs an ordered collection that uses the result comparator. A nested query needs recursive handling of relationship trees and `Child` events. The [change model](https://rindle.sh/docs/change-model) defines those structures. **Lifetime.** The callback captures only the consumer state. It does not capture the database handle or re-enter the writer. `query.destroy()` removes the subscription and its callback. Dropping the query handle alone would leave them active. The runtime's [subscription contract](https://rindle.sh/docs/replica-and-views#subscribing-to-changes) explains timing, replacement snapshots, and late consumers. ## Attach a raw change sink When your application already supplies `SourceChange` values, use the [raw Rust engine](https://rindle.sh/docs/quickstart). After source registration and query construction, attach a sink instead of a built-in `View`: ```rust let sink = graph.add_change_sink(top); graph.set_sink_edge(top, sink); let initial = graph.try_hydrate_change_sink(sink)?; // Apply `initial` as replacement state before any source pushes. // Push your SourceChange values through graph.try_source_push(...). let changes = graph.take_sink_changes(sink); // Apply `changes` in order to the same consumer. ``` The raw sink returns `Vec` without a transaction watermark or an `Update` envelope. Your application defines those boundaries. Draining the sink does not commit SQLite or make several source pushes atomic. For a repository example of this raw path: ```sh cd rust cargo run -p rindle-sqlite --example fold_deltas ``` ## Next steps - [Embedded SQLite and live queries](https://rindle.sh/docs/replica-and-views) — database setup, SQL transactions, and parallel query workers. - [Change model](https://rindle.sh/docs/change-model) — exact input and output payloads. - [Query shapes](https://rindle.sh/docs/supported-queries) — filters, ordering, and nested relationships. - [Raw engine quickstart](https://rindle.sh/docs/quickstart) — own the sources and push changes directly. --- [View this page on Rindle](https://rindle.sh/docs/example-rust) --- # 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) --- # 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) --- # 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) --- # Crates & API map What each crate owns, how they compose, and the API index — the map for navigating between the building blocks. Choose a Rust entry point by the work your application needs to own: - **`rindle-replica`** embeds SQLite, captures SQL writes, and maintains live queries. - **`rindle`** supplies the query builder and raw incremental engine. - **`rindle-sqlite`** connects raw engine sources to SQLite tables. Most embedders who want SQL and live results can start with [`rindle_replica::Db`](https://rindle.sh/docs/replica-and-views). The [raw engine quickstart](https://rindle.sh/docs/quickstart) is for applications that supply row changes directly. [Backends](https://rindle.sh/docs/backends) covers TypeScript integrations. ## Crate ownership and distribution The following declarations come from each crate's `Cargo.toml`. All crates in this table currently set `publish = false`, so these are repository dependencies, not crates.io installation instructions. The root repository license is [Apache-2.0](https://github.com/rindle-sh/rindle/blob/main/LICENSE). | Crate | Responsibility | Declared license | | --- | --- | --- | | `rindle-replica` | Embedded database, SQL capture, `Db`, `Cluster`, and query delivery | `Apache-2.0` | | `rindle` | Query AST, builder, sources, operators, and views | `Apache-2.0` | | `rindle-sqlite` | SQLite table sources, cost model, and operator spill storage | `Apache-2.0` | | `rindle-value` | Cells, owned rows, source changes, and shared errors | `Apache-2.0` | | `rindle-planner` | Result-preserving query-plan selection | `Apache-2.0` | | `rindle-cdc` | Capture SQLite row changes | `Apache-2.0` | | `rindle-writeplane` | SQL execution, schema admission, sessions, and write contracts | `Apache-2.0` | | `rindle-cdc-apply` | Apply captured changes and coordinate commits with consumers | `Apache-2.0` | | `rindle-wire` | Change encoding, normalized data, and protocol types | `Apache-2.0` | | `rindle-sqlite-ext` | SQLite extension integration for a host-owned connection | `Apache-2.0` | | `rindle-server` | The `rindled` daemon and its network APIs | `Apache-2.0` | | `rindle-replicator` | The replicated deployment's write authority and journal | `Apache-2.0` | | `rindle-dev-edge` | Route a local deployment's HTTP and WebSocket traffic | `Apache-2.0` | | `rindle-cli` | The `rindle` command-line application | `Apache-2.0` | | `rindle-source-postgres` | PostgreSQL source integration | `Apache-2.0` | | `rindle-cdc-gateway` | Source gateway process | `Apache-2.0` | | `rindle-backup` and `rindle-backup-sqlite` | Archive, snapshot, and recovery components | `Apache-2.0` | | `rindle-mobile` | Native mobile bindings over the embedded runtime | `Apache-2.0` | The separate `rindle-replica-node` manifest has no `license` field. Its `@rindle/replica` npm manifest also has no `license` field and is private. The [Node guide](https://rindle.sh/docs/backends#node-live-views) describes its repository build. The `@rindle/client`, `@rindle/wasm`, `@rindle/remote`, `@rindle/optimistic`, `@rindle/sql-client`, `@rindle/api-server`, `@rindle/daemon-client`, `@rindle/react`, `@rindle/tanstack`, `@rindle/cli`, and `create-rindle` manifests declare `Apache-2.0`. The separate `@rindle/room` and `@rindle/room-do` manifests do not declare a license field; `@rindle/room-do` is private. Library metadata and package publication are separate from hosted-service plans. This map describes the source in the repository. ## Workspace and dependencies The Cargo workspace is the virtual manifest at `rust/Cargo.toml`. The engine is one member at `rust/rindle`, beside the other `rindle-*` crates. Run repository Cargo commands from `rust/`. The workspace patches `libsqlite3-sys` to its vendored SQLite build. Applications outside the workspace must supply that patch themselves. The [embedded setup](https://rindle.sh/docs/replica-and-views#add-the-crates) includes a complete dependency block. `rindle` is std-only and does not link SQLite or require a C toolchain. Native SQLite integrations require the C toolchain. Browser builds use the core engine's `wasm` feature and the [`@rindle/wasm`](https://rindle.sh/docs/wasm-client) wrapper. ## `rindle-replica` — embedded SQL and live queries The Rust import is `rindle_replica`. Its primary application types are: | Type or method | Purpose | | --- | --- | | `Db::open(path)` | Open a file-backed database with one owner thread | | `Db::open_with(path, OpenOptions)` | Configure planning, journal, operator storage, and foreign keys | | `Db::exec_ddl(sql)` | Define or migrate tables before registration | | `Db::register_table(name)` | Admit a table and configure its source and change capture | | `Db::query(QueryId, Ast)` | Build and hydrate an independent query pipeline | | `Query::subscribe(callback)` | Deliver a snapshot and subsequent `Update` values | | `Query::destroy(self)` | Stop the query and release its subscription and pipeline | | `Db::write()` | Open the controlled writer transaction | | `WriteTxn::exec`, `exec_batch` | Run SQL inside that transaction | | `MutationSql::query` | Read within the writer transaction, including its own writes | | `WriteTxn::commit`, `commit_with_info` | Commit and synchronously deliver `Db` updates | | `WriteTxn::rollback` | Discard the open transaction | | `Db::read` | Run ordinary SQL on a physically read-only connection | | `Db::read_snapshot` | Assemble a registered query's current result as additions | `Db` and its query handles are `!Send`. The application controls table and query lifetimes. Data persists in SQLite, while registrations and callbacks are in-process state. A fresh database uses WAL. WAL2 is explicit configuration. The result stream has `Update::Hydrated`, `Update::Changed`, and the query-family variant `Update::PartitionHydrated`. A hydration is replacement state. `ChangeEvent` aliases `rindle::CaughtChange`, and `NodeData` aliases `rindle::CaughtNode`. See the [embedded guide](https://rindle.sh/docs/replica-and-views) for a complete program, schema limits, callback timing, error behavior, and migration lifecycle. ### Going multi-threaded: `Cluster` `Cluster::open(path, n_workers)` returns a coordinator and a bounded `Receiver`. Queries run on independent worker graphs. One query belongs to one worker, while all writes pass through the coordinator's writer. The coordinator remains `!Send`. A cluster commit returns after database commit, while worker delivery is asynchronous. Drain events continuously on another thread. The stream includes query updates, worker progress, and terminal query faults. `Changed` events can arrive before commit and can contain several slices of the same transaction. Stage them until the relevant workers emit `Progressed`. Discard staged changes for a faulted query and register it again. Do not wait until `commit` returns before starting the drain. `ClusterConsumer` adds an owned drain thread and normalized server subscriptions. `DerivationPool` is a lower-level integration for a host that already owns its writer and change capture. Prefer `Db` or `Cluster` unless that ownership is a requirement. The [scale-out guide](https://rindle.sh/docs/replica-and-views#scale-out-readers) shows the raw cluster lifecycle. ## `rindle` — the raw engine The crate has explicit namespaces. Import graph and value types from their modules, rather than assuming that every type is re-exported at the root: ```rust use rindle::change::SourceChange; use rindle::graph::{Graph, NodeId}; use rindle::value::{owned_row, OwnedRow, OwnedValue, SourceSchema, Value}; use rindle::{build_pipeline, table, view_schema, Ast, CaughtChange}; ``` The query builder and runtime play different roles: 1. `table(...).build()` constructs an `Ast`. 2. Sources supply base rows and `SourceSchema` values. 3. `build_pipeline` creates query operators and returns the top `NodeId`. 4. A `View` or a change sink consumes the result. 5. Source pushes maintain that result. `build_pipeline` does not create a view or commit a database transaction. The [engine lifecycle](https://rindle.sh/docs/how-it-works) and [raw quickstart](https://rindle.sh/docs/quickstart) show the complete sequence. ### Values and rows `rindle::value::OwnedValue` holds `Null`, `Bool`, `Int`, `Float`, `Str`, or `Json`. Use `OwnedValue::str(...)` to create a string. An internal `Absent` marker supports projected rows, so an exhaustive match must account for it too. `owned_row(Vec)` creates an `OwnedRow`. `row.col(index)` returns a borrowed `Value`, and `row.to_value_vec()` copies cells into owned values. Column positions follow the schema. A cell's representation is not a promise that every adapter admits it. The SQLite capture layer and JavaScript boundary enforce their own numeric limits. Read the [schema guide](https://rindle.sh/docs/schema) for database-backed applications. ### Views and sinks A `View` produces `ViewData`, whose entries contain rows and nested results. `Graph::try_hydrate` supplies the initial result. Source pushes update it, and `Graph::flush_view` notifies listeners for a changed batch. A change sink produces owned `CaughtChange` values. `Add` and `Remove` carry whole nodes. `Edit` carries old and new rows. `Child` carries a nested change. [The consumer example](https://rindle.sh/docs/example-rust) shows how to apply those events. ## `rindle-sqlite` — SQLite sources The Rust import is `rindle_sqlite`. `TableSource` implements a source over a SQLite table, and `GraphTableSourceExt` adds it to a graph. `ColumnDef` describes each column's name, engine type, and nullability. Raw source pushes write through to SQLite and update the graph. Ordinary SQL writes outside that path are not automatically observed. `rindle-replica` provides the controlled SQL writer when automatic capture is required. `SqliteCostModel` supplies statistics for `rindle-planner`. SQLite operator storage supplies an alternative to in-memory scratch state. The [planner guide](https://rindle.sh/docs/how-it-works#query-planner) explains where planning occurs. ## Next steps - [Embedded SQLite and live queries](https://rindle.sh/docs/replica-and-views) — start with `Db`. - [Raw Rust quickstart](https://rindle.sh/docs/quickstart) — integrate sources and row changes. - [Fold the delta stream](https://rindle.sh/docs/example-rust) — maintain an application-owned result. - [Supported queries](https://rindle.sh/docs/supported-queries) — inspect the builder's query limits. - [Run the daemon](https://rindle.sh/docs/daemon) — add network access to the runtime. --- [View this page on Rindle](https://rindle.sh/docs/crates) --- # 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) --- # Rust queries Look up supported Rust query shapes, examples, and current restrictions. This reference describes queries built with `rindle::table` and lowered into maintained engine pipelines. The [TypeScript builder](https://rindle.sh/docs/supported-queries-ts) uses the same engine, but its public methods differ. For example, Rust exposes `sum` and `avg`; the current TypeScript builder exposes counts. A maintained query is an `Ast`, not a SQL string. [Rindle SQL](https://rindle.sh/docs/sql-client) runs ordinary SQL requests. Its expression support does not define the operators available in a maintained query. ## How you express a query Build an AST, then register it with [`Db`](https://rindle.sh/docs/replica-and-views): ```rust use rindle_replica::QueryId; let ast = rindle::table("issues") .r#where("open", true) .where_op("priority", ">", 3) .order_by("created_at", "desc") .limit(50) .build(); let query = db.query(QueryId(1), ast)?; query.subscribe(|update| println!("{update:?}")); // Keep the query while its consumer needs updates. // At the end of that lifetime: query.destroy(); ``` This fragment assumes `db` has a registered `issues` table with those columns. The [embedded walkthrough](https://rindle.sh/docs/replica-and-views) includes database creation, registration, SQL writes, and cleanup. The [raw quickstart](https://rindle.sh/docs/quickstart) shows how to build a graph without `Db`. The builder methods construct an immutable query description. Registration resolves table and column names, constructs the pipeline, and hydrates it. `Db` captures SQL writes and calls subscribers after commit. See [the change model](https://rindle.sh/docs/change-model) for payloads and the distinct `Cluster` streaming contract. ## Correlated relationships and EXISTS Relationships are correlated subqueries. Inside a `sub` / `sub_as` / `where_exists` closure, the closure receives the parent row. `row.col("…")` references a **parent** column, and using it as a `where` value defines the correlation (it is not a filter): ```rust // issues, each carrying its comments (a materialized relationship) let with_comments = rindle::table("issues") .sub_as("comments", |row| { rindle::table("comments").r#where("issue_id", row.col("id")) }) .build(); // only issues that have at least one comment (an EXISTS filter) let commented = rindle::table("issues") .where_exists(|row| { rindle::table("comments").r#where("issue_id", row.col("id")) }) .build(); // the negation let uncommented = rindle::table("issues") .where_not_exists(|row| { rindle::table("comments").r#where("issue_id", row.col("id")) }) .build(); ``` ## Aggregates: a live `count` Relationship aggregates attach a scalar to each parent row. `count_as` counts matching children. `sum_as` and `avg_as` use a numeric child column: ```rust let with_totals = rindle::table("issues") .count_as("commentCount", |issue| { rindle::table("comments").r#where("issue_id", issue.col("id")) }) .sum_as("totalEstimate", "estimate", |issue| { rindle::table("tasks").r#where("issue_id", issue.col("id")) }) .avg_as("averageEstimate", "estimate", |issue| { rindle::table("tasks").r#where("issue_id", issue.col("id")) }) .build(); ``` A parent without matching children reads `0` for count and `NULL` for sum or average. Sum and average ignore `NULL` input values. The engine maintains these values as children enter, leave, or change their numeric cells. ## 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), you can mark it `scalar`. 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. ```rust use rindle::ExistsOpts; // only the project that owns issue #7 — resolved once, then a plain literal filter let owner = rindle::table("projects") .where_exists_with( |row| rindle::table("issues").r#where("id", 7).r#where("project_id", row.col("id")), ExistsOpts { scalar: true }, ) .build(); ``` ```ts import { exists } from "@rindle/client"; 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 `count()`, `sum(column)`, and `avg(column)` reshape a query into aggregate rows. Without `group_by`, the result contains one global row. With it, each distinct group has one row. `having` filters those aggregate rows: ```rust use rindle::table; // one [count] row, maintained as rows enter and leave the filter let ast = table("issue").r#where("open", true).count().build(); // one [status, count] row per distinct status, HAVING count > 3 let ast = table("issue") .group_by("status") .count() .having(|c| c.where_op("count", ">", 3)) .build(); // one [status, sum] row per status let ast = table("issue").group_by("status").sum("estimate").build(); // one [avg] row; NULL when there are no non-NULL estimates let ast = table("issue").avg("estimate").build(); // filter the PARENT by a child count — issues with more than 10 comments. // The alias must name a `count_as` relationship already on this query. let ast = table("issue") .count_as("comments", |row| table("comment").r#where("issue_id", row.col("id"))) .having_count("comments", ">", 10) .build(); ``` `having` addresses the aggregate's *output* columns — the `group_by` columns and the synthetic `count`, `sum`, or `avg` column. `where` filters base rows before aggregation. `having_count` gates a *parent* row by a child relationship's count. v1 accepts **high-pass** predicates only (see the matrix and rejections below). ## Supported shapes **fetch** means initial hydration. **push** means incremental maintenance. **view** means materialized result support. ✅ is supported, ⚠️ has the stated restriction, and ❌ is unavailable. | Query shape | fetch | push | view | Notes | |---|:---:|:---:|:---:|---| | Simple `where` (`=`,`!=`,`<`,`>`,`<=`,`>=`) | ✅ | ✅ | ✅ | via `.r#where` / `.where_op` | | `IS` / `IS NOT` (null-aware equality) | ✅ | ✅ | ✅ | `IS NULL` matches null; ordinary `= NULL` does not | | `LIKE` / `ILIKE` / `NOT ILIKE`, incl. `\%`/`\_`/`\\` escapes | ✅ | ✅ | ✅ | memory matcher agrees with SQLite | | `AND` / `OR` of leaf conditions | ✅ | ✅ | ✅ | | | `IN` / `NOT IN` over a literal list | ✅ | ✅ | ✅ | via `.where_in` | | Sibling relationships (multiple `sub` on a row) | ✅ | ✅ | ✅ | | | Nested relationships (`sub` with its own `sub`) | ✅ | ✅ | ✅ | | | `start_at` / `start_after` paging bound | ✅ | ✅ | ✅ | | | `limit` (ordered take / exists cap) | ✅ | ✅ | ✅ | via `.limit` | | `where_exists` (correlated EXISTS) | ✅ | ✅ | ✅ | the engine picks the cheaper drive side (parent- or child-driven) internally | | `where_not_exists` (NOT EXISTS) | ✅ | ✅ | ✅ | | | 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` | ✅ | ✅ | ✅ | aliases uniquified to distinct query-local slots | | Deepest-nested child push | ✅ | ✅ | ✅ | emits `CaughtChange::Child` | | 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 JS view unwraps to `row \| null` | | Relationship-level `.one()` (a singular `sub`) | ⚠️ | ⚠️ | ⚠️ | view layer implemented + unit-tested, not yet reachable via a query (builds plural today) | | Aggregate: `count` of a correlated child (`count_as`) | ✅ | ✅ | ✅ | a scalar count per parent; an empty child reads `0` | | Relationship `sum_as` / `avg_as` | ✅ | ✅ | ✅ | numeric child columns; empty/all-null input reads `NULL` | | Top-level `sum()` / `avg()` | ✅ | ✅ | ✅ | one global result or one result per `group_by` key | | `min` / `max` | ❌ | ❌ | ❌ | no current builder or aggregate variant | | Top-level `count()` (global aggregate) | ✅ | ✅ | ✅ | reshapes the result to one `[count]` row instead of materializing rows | | `group_by` + `count()` (grouped aggregate) | ✅ | ✅ | ✅ | one `[group…, count]` row per distinct value-tuple, keyed and sorted by the group columns | | `having` (filter post-aggregation rows) | ✅ | ✅ | ✅ | clauses address the `group_by` columns and the synthetic `count` column | | `having_count` (filter a parent by a child count) | ⚠️ | ⚠️ | ⚠️ | gates a parent by a `count_as` 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` shapes the result; the engine also needs keys and columns used by filters, ordering, and correlations | | Legacy ZQL `static` parameter nodes | ❌ | ❌ | ❌ | not represented in the AST; distinct from query arguments and query families | ¹ **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 does not remove every internal column.** Result schemas record the selection, while raw row buffers use source column positions. The engine also retains columns needed to resolve the query. The SQLite source can still read its full declared column list. Do not interpret a narrow result as proof of a narrow 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. ## Parameterized query families An ordinary builder query embeds its argument values as literals. That is enough for a distinct `Db::query` registration. It needs no SQL-style placeholder syntax. The raw engine and `Cluster` also support **query families**: one pipeline maintains partitions for several root equality values. The raw entry point is `rindle::build_family_pipeline`; `Cluster::family`, `bind`, and `unbind` expose the runtime lifecycle. These differ from the removed ZQL `static` parameter node. Families require at least one distinct parameter column and do not accept a root aggregate. Each bound partition has its own hydration and top-N window. Binding changes occur between source pushes. This is an advanced sharing API; ordinary embedded queries can start with `Db`. ## Relationship slots are query-local When two or more EXISTS conditions sit under a top-level `AND` / `OR`, the builder uniquifies their aliases to distinct slots (`comments` → `comments_0`, the next → `_1`, …). The slot layout is **derived from the query AST**, not from the source schema's declared relationships. So a production-shaped schema that declares only the table's real relationship names (or none) builds these shapes. The wasm path needs no synthesized gate slots. The slot order is materialized relationships first, then EXISTS gates in `where`-tree pre-order. That one tree is shared by the dataflow joins, the EXISTS gates, and the view materialization, so their relationship ids agree by construction. ## Build-time rejections These are genuine limitations surfaced as a `BuildError`, not normalization artifacts: - **A root aggregate combined with row-shaping** — pairing `count`, `sum`, or `avg` with `select`, relationships, `order_by`, `limit`, paging, or `one` is rejected. Root aggregates also reject correlated subqueries in `where` or `having`. - **A child-count predicate that is true at zero** — for example, `= 0`, `>= 0`, or `!= 1`. A childless parent has no aggregate group, so these are rejected. Use a single numeric comparison that is false at zero, such as `> 0`, `>= 2`, `= 2`, or `!= 0`. - **A child-driven `NOT EXISTS`** — the engine maintains `NOT EXISTS` parent-driven only, and nothing in the fluent builder or the planner ever asks for the child-driven form. A hand-authored wire `Ast` that marks one is rejected with `BuildError::Unsupported("flipped NOT EXISTS is not lowered")`. - **An EXISTS subquery carrying a paging bound or a nested relationship** → `BuildError::Unsupported`. - **A bare top-level EXISTS aliased the same as a materialized relationship** → `BuildError::Unsupported` (one relationship per slot). A bare EXISTS is not alias-uniquified, so it collides with a `sub` of the same name. Two EXISTS under a top-level `and` / `or` are uniquified to distinct slots and never collide. Unknown tables and columns are also build-time errors. Referencing a table you never registered fails with a `BuildError` at `Db::query` time, and an undeclared relationship name surfaces as `BuildError::UnknownRelationship`. ## A note on value types Rows use positional cells. Read them through `OwnedRow::col`, which returns a borrowed `rindle::value::Value`. Match the declared type instead of assuming that a number arrives as an integer variant: ```rust use rindle::value::{OwnedRow, Value}; fn text_id(row: &OwnedRow) -> &str { match row.col(0) { Value::Str(bytes) => std::str::from_utf8(bytes).expect("valid UTF-8"), other => panic!("expected a text id, got {other:?}"), } } ``` SQLite `INTEGER` cells admitted to the engine's number domain arrive as floats. The capture layer rejects integers that cannot round-trip through `f64`. Text identifiers avoid that conversion. Exact `BIGINT` SQL columns have different query-footprint restrictions; see [schema types](https://rindle.sh/docs/schema). ## Next steps - [Embedded walkthrough](https://rindle.sh/docs/replica-and-views) — create a database, write SQL, and observe a query. - [The change model](https://rindle.sh/docs/change-model) — `Add` / `Remove` / `Edit` / `Child` in depth. - [Replica and views](https://rindle.sh/docs/replica-and-views) — how deltas are derived and delivered. - [Crates](https://rindle.sh/docs/crates) — `rindle`, `rindle-replica`, and `rindle-sqlite`. --- [View this page on Rindle](https://rindle.sh/docs/supported-queries) --- # The change model Learn the changes sources accept, the changes views emit, and the rule that makes replay correct. Rindle emits result changes as it maintains a query. A source change describes a row mutation. A result change describes the effect on a query. One source change can produce zero, one, or many result changes. The change payload and the delivery boundary are separate contracts. The raw engine, `Db`, and `Cluster` use the same owned result types, but expose different batch and commit boundaries. ## Two kinds of change | Direction | Public Rust type | Meaning | | --- | --- | --- | | Input | `rindle::change::SourceChange` | One base-table row mutation | | Output | `rindle::CaughtChange` | One change to a query result | The [raw engine](https://rindle.sh/docs/quickstart) accepts source changes from your program. A SQLite `TableSource` writes them through to its table. The [embedded runtime](https://rindle.sh/docs/replica-and-views) instead captures source changes from SQL executed through its controlled writer. The replica crate re-exports the output types: ```rust // These are aliases for the same types, not different event formats. pub use rindle::CaughtChange as ChangeEvent; pub use rindle::CaughtNode as NodeData; ``` ## The input side: `SourceChange` The source-change type has three variants. Here `Row` means `rindle::value::OwnedRow`: ```rust pub enum SourceChange { Add(Row), Remove(Row), Edit { row: Row, old: Row }, } ``` `Add` carries the inserted row. `Remove` carries the removed row. `Edit` carries the complete old row and its replacement. Column positions follow the source schema. If an edit changes an identity or correlation key, the engine can split it into a removal and addition for downstream operators. Raw callers must supply accurate old rows and use the source's declared column types. The embedded runtime obtains these values from SQLite's preupdate hook. See [values and rows](https://rindle.sh/docs/crates#values-and-rows) for the public cell types. ## The output side: `CaughtChange` A `CaughtChange` owns its rows and relationship trees. It can leave the graph's thread without retaining a graph borrow: ```rust pub enum CaughtChange { Add(CaughtNode), Remove(CaughtNode), Edit { old: OwnedRow, row: OwnedRow, }, Child { row: OwnedRow, rel: RelId, change: Box, }, } ``` | Variant | Consumer action | | --- | --- | | `Add(node)` | Insert the row and its nested results | | `Remove(node)` | Remove the row and its nested results | | `Edit { old, row }` | Replace the row cells; retain its relationship state | | `Child { row, rel, change }` | Find the parent row, then apply the nested change in relationship slot `rel` | These are result events. An SQL `UPDATE` can produce a `Remove` when a row leaves a filter, or an `Add` when it enters. Do not infer the original SQL operation from the output variant. ### `CaughtNode` `Add` and `Remove` carry a complete node: ```rust pub struct CaughtNode { pub row: OwnedRow, pub relationships: BTreeMap>, } ``` `OwnedRow` and `RelId` are in `rindle::value`. A relationship is identified by a query-local slot, not its string name. Each child vector preserves the query's sort order. A flat query has an empty relationship map. Result events do not contain array insertion positions. A consumer that exposes ordered rows must use the result schema's identity and ordering rules. The [fold example](https://rindle.sh/docs/example-rust) uses a flat query ordered by its text primary key. Use the built-in view or a higher-level store when you do not need a custom fold. ## Reconstruction invariant Starting from a full hydration and applying valid changes in order reconstructs the query result over the same source data. This is the contract: **view-after-write == fresh-query**. The invariant applies at the delivery boundary your runtime defines. It does not mean that an arbitrary prefix of a streamed transaction is committed. A consumer must also handle replacement snapshots, faults, and query teardown. ## Receiving deltas: the change sink The raw engine attaches a change sink to the pipeline's top node: ```rust use rindle::CaughtChange; let sink = graph.add_change_sink(top); graph.set_sink_edge(top, sink); let initial: Vec = graph.try_hydrate_change_sink(sink)?; // After your graph.try_source_push(...) calls: let delta: Vec = graph.take_sink_changes(sink); ``` This fragment assumes an existing graph and pipeline. The [raw quickstart](https://rindle.sh/docs/quickstart) supplies their setup. Hydration returns the full initial result as `Add` events. `take_sink_changes` drains the accumulated deltas. Neither method commits a database transaction. The host defines the batch boundary and any transaction or recovery policy. ## Snapshot boundaries ### `Db`: synchronous, committed callbacks `Query::subscribe` immediately supplies the snapshot cached at registration. Subscribe before further writes. A late subscriber does not receive the missing history; register a fresh query for an independent consumer. Subsequent callbacks run synchronously during `Db` commits, after the database commits. `Update::Changed { tx_id, changes }` carries a query's changes for that transaction. Queries with no result changes need not receive a callback. `Update::Hydrated { tx_id, changes }` can recur after recovery from an oversized derivation. Replace the existing result on every hydration. Ordinary queries do not use `Update::PartitionHydrated`, which replaces one query-family partition. Keep callbacks short. Do not re-enter the database or panic in them. A callback panic happens after the commit and does not roll back the data. Call `Query::destroy` to stop delivery; dropping the handle alone does not unregister it. ### `Cluster`: provisional slices and commit markers A cluster can send `Changed` slices while the writer transaction remains open. Several slices can share the same `TxId`. They are provisional until the hosting worker sends `ClusterEvent::Progressed` through that transaction. Stage slices in arrival order. Release them together when their worker progresses. For an atomic result spanning queries on several workers, wait for all relevant workers. A successful coordinator commit alone does not confirm that a consumer has received every query update. `ClusterEvent::Faulted` is terminal for the affected query. Discard its staged slices and register it again to obtain a replacement snapshot. A rollback after streaming is one reason that a query can fault. The [cluster guide](https://rindle.sh/docs/replica-and-views#scale-out-readers) shows how to drain the bounded channel continuously. A write-then-drain loop can deadlock. `ClusterConsumer` supplies a normalized drain, but its `DrainSink::batch` output is also eager. Downstream consumers release batches through its progress frames. ## Next steps - [Fold the delta stream](https://rindle.sh/docs/example-rust) — maintain a result from `Db` updates and compare it with SQL. - [Embedded SQLite and live queries](https://rindle.sh/docs/replica-and-views) — setup, writes, subscriptions, and recovery. - [How it works](https://rindle.sh/docs/how-it-works) — sources, operators, hydration, and pushes. - [Crates](https://rindle.sh/docs/crates) — public modules, values, and runtime types. --- [View this page on Rindle](https://rindle.sh/docs/change-model) --- # 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)