Build a synced app

Synced-app quickstart

Build a synced, local-first app end to end on one page: a SQL schema as the source of truth, generated TypeScript, an optimistic browser store, isomorphic mutators, your API authority, and the rindled daemon (self-hosted or on Rindle Cloud). Every file, every command.

View as Markdown

The whole three-tier architecture as a copy-paste recipe: a browser client with its own optimistic engine, a stateless API server that is your app’s authority, and the Rindle data tier — a rindle-replicator write-master that owns the authoritative data plus a rindled read-follower (the colocated pair) that streams live deltas — run it yourself or let Rindle Cloud run it for you.

It uses the same normalized schema as the full issue tracker (user · issue · tag · comment), so this is a true subset of that app — follow the per-tier pages for depth.

If you want a ready-to-run project instead of building it by hand, create-rindle scaffolds the same shape as a small TanStack Start app. This page is the manual version.

The shape in one line. The browser sends only names and arguments up — query names to subscribe, mutation names to write — and gets normalized row deltas back. Each mutator is one body, run on both tiers: the browser drives it as the instant local prediction, the server drives the same body as the authority, and the engine rebases the client onto it. You write no cache, no refetch, no rollback code — and no second copy of your writes in SQL.

Your schema is SQL. The source of truth is the SQLite schema the write-master holds. You evolve it with SQL migrations (by hand, or with Drizzle), and the TypeScript schema your app imports is generated from the live follower with rindle schema gen — never hand-written. That one rule keeps all three tiers in lockstep.

What you’ll build

A live issue list with a per-issue comment count. The schema is SQL; everything else is a handful of files:

File Tier What’s in it
migrations/0001_init.sql schema (source of truth) CREATE TABLE ×4 + indices — the real schema
shared/schema.gen.ts generated rindle schema gen output — don’t hand-edit
shared/app-def.ts shared contract relationships, the query builder, the isomorphic mutators (one body, both tiers)
src/IssueList.queries.ts shared contract the named query, co-located with its component
src/rindle-client.ts + UI browser createRindleClient, reads via @rindle/react, optimistic writes
server/api.ts API server named queries → ASTs, the same mutators driven with server authority

0 · Install

You need Node ≥ 22.18 (the API server below runs .ts files with node directly — no build step) and a Vite + React project to build in. Scaffold one if you don’t have one, then add the Rindle packages:

pnpm create vite my-app --template react-ts && cd my-app && pnpm install

pnpm add @rindle/optimistic @rindle/client @rindle/wasm @rindle/react   # browser
pnpm add @rindle/api-server @rindle/daemon-client                       # API server
pnpm add zod                                                            # mutator arg schemas
pnpm add -D @rindle/cli concurrently                                    # toolchain + process runner (step 6)

@rindle/cli ships the rindle CLI and the daemon binaries as prebuilt, per-platform artifacts — nothing compiles. @rindle/optimistic is the browser store, @rindle/client the query builders and mutator vocabulary (and your generated schema), @rindle/wasm the in-browser IVM engine, @rindle/react the bindings (skip it if you’re not on React — the store has a framework-agnostic core), @rindle/api-server the authority’s request handlers, and @rindle/daemon-client the typed control-plane client.

Then scaffold the project’s Rindle files:

npx rindle init        # writes rindle.ncl (the colocated pair, loopback) + migrations/

That’s all the setup there is — you won’t run anything until step 6, where one pnpm dev starts the pair (the rindle-replicator write-master + its rindled follower), applies your migrations, generates the TypeScript schema, and boots your app. (You don’t list tables anywhere; the follower introspects whatever schema your migrations create on the master. See run the daemon for the full config, restart recovery, and auth.)

1 · The schema, in SQL

Author the normalized schema as one additive SQL migration: four tables joined back together at query time. Every table needs a single PRIMARY KEY; columns are TEXT / INTEGER / REAL (no blob / bigint yet); use IF NOT EXISTS so a re-run is safe. The indices keep the joins fast in both directions.

-- migrations/0001_init.sql   (npx rindle migrate create init scaffolds the file)
CREATE TABLE IF NOT EXISTS user (id TEXT, name TEXT, PRIMARY KEY (id));

CREATE TABLE IF NOT EXISTS issue (
  id TEXT, title TEXT, status TEXT, priority TEXT, ownerId TEXT,
  createdAt REAL, updatedAt REAL, PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS issue_created ON issue (createdAt DESC, id);  -- the paginated window
CREATE INDEX IF NOT EXISTS issue_owner   ON issue (ownerId);            -- the owner join, reverse

CREATE TABLE IF NOT EXISTS tag (id TEXT, issueId TEXT, name TEXT, PRIMARY KEY (id));
CREATE INDEX IF NOT EXISTS tag_issue ON tag (issueId, name);
CREATE INDEX IF NOT EXISTS tag_name  ON tag (name);

CREATE TABLE IF NOT EXISTS comment (id TEXT, issueId TEXT, authorId TEXT, body TEXT, createdAt REAL, PRIMARY KEY (id));
CREATE INDEX IF NOT EXISTS comment_issue  ON comment (issueId, createdAt);
CREATE INDEX IF NOT EXISTS comment_author ON comment (authorId);

There’s nothing to run yet. The pnpm dev you’ll write in step 6 applies pending migrations and generates the TypeScript schema from the live daemon — on boot and again on every migrations/ change. (Against an already-running daemon — production, say — the standalone commands are npx rindle migrate apply and npx rindle schema gen --out shared/schema.gen.ts.)

shared/schema.gen.ts is a generated artifact — one const per table (sorted by name) plus the createSchema aggregate. Don’t hand-edit it; this is what the daemon emits for the migration above:

// shared/schema.gen.ts — Generated by `rindle schema gen`. Do not edit by hand.
import { createSchema, number, string, table } from "@rindle/client";

export const comment = table("comment")
  .columns({ id: string(), issueId: string(), authorId: string(), body: string(), createdAt: number() })
  .primaryKey("id");

export const issue = table("issue")
  .columns({
    id: string(), title: string(), status: string(), priority: string(),
    ownerId: string(), createdAt: number(), updatedAt: number(),
  })
  .primaryKey("id");

export const tag = table("tag")
  .columns({ id: string(), issueId: string(), name: string() })
  .primaryKey("id");

export const user = table("user")
  .columns({ id: string(), name: string() })
  .primaryKey("id");

export const schema = createSchema({ tables: [comment, issue, tag, user] });

The declared SQL type drives the generated type. rindle schema gen reads each column’s declared type: TEXTstring(), INTEGER / REALnumber(), BOOLEANboolean(), JSONjson<T>(). Refine a json<T>() element type by hand after generating. Two limits: integers beyond ±(2⁵³−1) lose precision (the engine’s number domain is f64), and blob isn’t supported yet.

Using Drizzle (or any tool)? Author your schema however you like, run its generator (drizzle-kit generate, sqlite dialect — offline, no DB) to emit *.sql, and point the applier at it: npx rindle migrate apply --dir ./drizzle. The daemon never opens your database file — all DDL flows through it. SQL is the only contract. Full details and the drizzle-kit affinity caveat: schema & migrations.

Need browser-only tables? Keep shared/schema.gen.ts generated. Define drafts, selections, and other private UI tables in a hand-written module and combine them with extendSchema(generatedSchema, { tables: [localTable] }); pass that extended schema to the browser client, while the API server keeps using the generated synced schema. See schema & migrations.

2 · The shared contract

Both tiers import the generated synced schema, the relationships (the joins, declared once), a query builder, and the mutators. A mutator is written once, as a generator that yields logical write ops (yield tx.insert(...)) instead of touching a database, and is paired with the zod schema for its args by shared(args, gen):

  • The browser drives the body synchronously against its local tables — the optimistic prediction. It is re-invoked on every rebase, so it must not read clocks or randomness: generate ids and timestamps at the callsite and pass them in as args.
  • The API server drives the same body inside an authoritative transaction, rendering each yielded op to SQL (step 5). No hand-written SQL twins.

The acting user is never an argument — each tier injects it as ctx.user (the client its local user, step 4; the server its authenticated principal, step 5), so it can’t be spoofed over the wire.

// shared/app-def.ts — imported by BOTH the browser and the API server
import { defineMutators, defineRelationships, newQueryBuilder, rel } from "@rindle/client";
import type { MutationGen, MutatorCtx, Row } from "@rindle/client";
import type { ClientRegistry } from "@rindle/optimistic";
import { z } from "zod";
import { schema, comment, issue, tag, user } from "./schema.gen.ts";   // ← the generated schema (step 1)

export { schema, comment, issue, tag, user };
export const q = newQueryBuilder(schema);   // one builder, shared by the *.queries.ts files
export type Issue = Row<typeof issue>;

// Each join declared ONCE as a typed value: the correlation lives here, not at every callsite.
export const rels = defineRelationships({
  issueOwner: rel(issue, user, { ownerId: "id" }),       // an issue → its owner
  issueComments: rel(issue, comment, { id: "issueId" }), // an issue → its comments
  issueTags: rel(issue, tag, { id: "issueId" }),         // an issue → its tags
});

// One zod schema per mutator: the server parses the UNTRUSTED wire args through it; both
// tiers derive the arg TYPE from it. Note there's no `owner`/`author` arg — that's ctx.user.
export const createIssueArgs = z.object({
  id: z.string(), title: z.string(), status: z.string(), priority: z.string(), createdAt: z.number(),
});
export type CreateIssueArgs = z.infer<typeof createIssueArgs>;

export const addCommentArgs = z.object({
  id: z.string(), issueId: z.string(), body: z.string(), createdAt: z.number(),
});
export type AddCommentArgs = z.infer<typeof addCommentArgs>;

// `shared` bound to the schema: every tx.insert/update/delete below checks its table,
// column names, value types, and pk columns at compile time.
const { shared } = defineMutators(schema);

// The ISOMORPHIC mutators. Only (name, args) ever crosses a wire — never these effects.
export const mutators = {
  createIssue: shared(createIssueArgs, function* (tx, a: CreateIssueArgs, ctx: MutatorCtx): MutationGen {
    // insert the acting user's row if absent — renders ON CONFLICT DO NOTHING on the server
    yield tx.insertIgnore("user", { id: ctx.user, name: ctx.user });
    yield tx.insert("issue", {
      id: a.id, title: a.title, status: a.status, priority: a.priority,
      ownerId: ctx.user, createdAt: a.createdAt, updatedAt: a.createdAt,
    });
  }),
  // writes the comment table → the issue's live commentCount ticks up on its own
  addComment: shared(addCommentArgs, function* (tx, a: AddCommentArgs, ctx: MutatorCtx): MutationGen {
    yield tx.insertIgnore("user", { id: ctx.user, name: ctx.user });
    yield tx.insert("comment", { id: a.id, issueId: a.issueId, authorId: ctx.user, body: a.body, createdAt: a.createdAt });
    yield tx.update("issue", { id: a.issueId, updatedAt: a.createdAt });   // pk + only the columns that change
  }),
  setStatus: shared(
    z.object({ id: z.string(), status: z.string(), updatedAt: z.number() }),
    function* (tx, a): MutationGen {
      yield tx.update("issue", { id: a.id, status: a.status, updatedAt: a.updatedAt });
    },
  ),
  // an isomorphic READ: `yield tx.row(...)` evaluates to the row on both tiers, so the
  // ownership rule lives in the one body — a non-owner's delete is a no-op locally AND
  // in the server's authoritative run (where ctx.user is the verified principal).
  deleteIssue: shared(z.object({ id: z.string() }), function* (tx, a, ctx): MutationGen {
    const cur = (yield tx.row("issue", { id: a.id })) as Issue | undefined;
    if (!cur || cur.ownerId !== ctx.user) return;
    yield tx.delete("issue", { id: a.id });
  }),
} satisfies ClientRegistry;

The op vocabulary is tx.insert / tx.update (pk + changed columns) / tx.upsert / tx.insertIgnore / tx.delete — plus reads: yield tx.row(table, pk) for a point read and yield tx.query(builder) for a full query, both seeing the current state plus this transaction’s own earlier writes, on every tier. Multi-op helpers are generators spread with yield*; a single-op helper returns one op to yield.

3 · The named query

Remote subscriptions must be named: define each with defineQuery, co-located with the component that reads it. The same value is callable on the client (it stamps its result with the wire identity, so a subscription syncs) and registerable on the server (step 5). Its validate step runs on both tiers, so client and server build a byte-identical AST from the same untrusted args.

This one windows the (big) issue table newest-first and carries a live commentCount — a correlated count over the comment table, maintained incrementally as comments come and go (no re-scan). That’s the normalized payoff:

// src/IssueList.queries.ts — co-located with the component below
import { defineQuery } from "@rindle/client";
import { q, rels } from "../shared/app-def.ts";

type IssuesPageArgs = { limit: number };

export const issuesPageQuery = defineQuery(
  "issuesPage",
  (raw): IssuesPageArgs => {
    const limit = (raw as IssuesPageArgs).limit;
    if (!Number.isInteger(limit) || limit < 1 || limit > 1000) throw new Error("bad limit");
    return { limit };
  },
  ({ limit }: IssuesPageArgs) =>
    q.issue
      .orderBy("createdAt", "desc")
      .limit(limit)
      .countAs("commentCount", rels.issueComments),  // rows: Issue & { commentCount: number }
);

Joining the owner row or the tag list onto each issue is the same .sub(alias, rel, fragment) move, composed as fragments so a component tree declares its own data — see compose the UI with fragments. The issue tracker does exactly that.

4 · The browser client

createRindleClient boots the wasm engine, opens the ws subscription to the daemon, resolves query leases through your API server, and runs the mutation queue — one call wires the whole tier.

// src/rindle-client.ts
import { createRindleClient } from "@rindle/optimistic";
import { mutators, schema } from "../shared/app-def.ts";

export const app = await createRindleClient({
  schema,
  mutators,
  user: () => currentUser(),                       // the acting principal a mutator sees as ctx.user
  api: {
    url: "",                                       // same-origin: posts to /api/rindle/* (proxied in step 6)
    headers: () => ({ "x-user": currentUser() }),  // a real app sends a session/JWT
  },
  daemon: { wsUrl: import.meta.env.VITE_FLEET_WS, affinity: true }, // stable fleet edge
  onRejected: (envelope, reason) => showToast(`${envelope.name} rejected: ${reason}`),
});
// (currentUser / showToast are your app's helpers — any session id and toast UI work.)

Read live views with useQuery (re-renders only when the result changes), write through app.mutate.<name>(args) — which drives the mutator’s body against the local tables synchronously, so the view updates before the call returns:

import { createRoot } from "react-dom/client";
import { Rindle, useQuery } from "@rindle/react";
import { issuesPageQuery } from "./IssueList.queries.ts";
import { app } from "./rindle-client.ts";

createRoot(root).render(
  <Rindle store={app.store}>
    <IssueList />
  </Rindle>,
);

function IssueList() {
  const rows = useQuery(issuesPageQuery({ limit: 50 }));  // live, reference-stable
  return (
    <ul>
      {rows.map((r) => (
        <li key={r.id} onClick={() => app.mutate.setStatus({ id: r.id, status: "done", updatedAt: Date.now() })}>
          {r.title} — {r.status} · {r.commentCount} comments
        </li>
      ))}
    </ul>
  );
}

// creating an issue is one optimistic call — the list shows it instantly
// (ids and timestamps are generated at the callsite; the author is ctx.user, not an arg):
const id = crypto.randomUUID();
app.mutate.createIssue({ id, title: "ship it", status: "todo", priority: "medium", createdAt: Date.now() });
// add a comment — the issue's commentCount ticks up the moment the call returns:
app.mutate.addComment({ id: crypto.randomUUID(), issueId: id, body: "on it", createdAt: Date.now() });

The mutation’s name and arguments (never its effects) are pushed to the API server; the confirmed deltas stream back and the client rebases. If the authoritative run rejects the write, the optimistic rows vanish on their own and onRejected fires — no rollback code. See the browser client for folded high-frequency writes, local query resolution, and the loading/pending signals.

5 · The API server

Your app’s authority — stateless and serverless-shaped. It authenticates the caller, resolves named queries to ASTs (re-running each validator on the untrusted args), drives the same mutators the browser predicted, and talks to the data tier: reads and materializations go to the follower, writes to the write-master. It holds no data and no live state — and, because the mutators are shared, no second copy of your writes:

// server/api.ts
import { createServer } from "node:http";
import { createRindleApiServer, registerQueries, sharedApiMutators, SplitDaemonClient } from "@rindle/api-server";
import type { MutationContext } from "@rindle/api-server";
import type { MutatorCtx } from "@rindle/client";
import { HttpRindleDaemonClient } from "@rindle/daemon-client";
import { issuesPageQuery } from "../src/IssueList.queries.ts";
import { mutators, schema } from "../shared/app-def.ts";

type User = string | undefined;

// (a) named queries → ASTs: just the list of co-located defineQuery values.
const queries = registerQueries<User>([issuesPageQuery]);

// (b) the SAME mutators the browser predicts, auto-driven: parse the untrusted wire args
//     through each mutator's .args schema, inject the AUTHENTICATED principal as ctx.user,
//     drive the body, render every yielded op to SQL. Throw to HARD-reject; a run that
//     legitimately changes nothing (deleteIssue's ownership guard) is an accepted-but-no-op
//     — either way the client's optimistic write snaps back on its own.
const sharedCtx = (ctx: MutationContext<User>): MutatorCtx => {
  if (!ctx.user) throw new Error("unauthenticated");
  return { user: ctx.user };
};

// (c) tie them together. One topology (design 214): READS go to the follower, WRITES go to
//     the replicator write-master — SplitDaemonClient routes each leg. (Local defaults below;
//     in dev the write leg is set by RINDLE_REPLICATOR_URL — see step 6.)
const reads = new HttpRindleDaemonClient({
  baseUrl: process.env.RINDLE_DAEMON_URL ?? "http://127.0.0.1:7600",     // the follower — reads
  headers: { authorization: `Bearer ${process.env.RINDLE_DAEMON_TOKEN ?? ""}` }, // unset in local dev
});
const api = createRindleApiServer<User>({
  daemon: new SplitDaemonClient(
    new HttpRindleDaemonClient({
      baseUrl: process.env.RINDLE_REPLICATOR_URL ?? "http://127.0.0.1:7611", // the write-master — writes
      headers: { authorization: `Bearer ${process.env.RINDLE_REPLICATOR_TOKEN ?? ""}` },
    }),
    reads,
  ),
  schema,                                                      // drives the SQL renderer for the yielded ops
  queries,
  mutators: sharedApiMutators(mutators, sharedCtx),
  authorizeQuery: ({ user }) => typeof user === "string" && user.length > 0,
  authorizeMutation: ({ user }) => typeof user === "string" && user.length > 0,
});

// (d) you own the HTTP — @rindle/api-server is transport-agnostic (node:http, a
//     Worker, a Lambda, Hono…). Mount the JSON handlers on api.routes.
createServer((req, res) => {
  void (async () => {
    const body = JSON.parse(await readBody(req));
    const ctx = { user: req.headers["x-user"] as string | undefined, request: req }; // verify a JWT here in prod
    const out =
      req.url === api.routes.query  ? await api.handleQueryJson(body, ctx)  :
      req.url === api.routes.mutate ? await api.handleMutateJson(body, ctx) :
      { error: "not found" };
    res.writeHead(200, { "content-type": "application/json" });
    res.end(JSON.stringify(out));
  })();
}).listen(7700);

function readBody(req: import("node:http").IncomingMessage): Promise<string> {
  return new Promise((resolve, reject) => {
    let body = "";
    req.on("data", (chunk) => (body += chunk));
    req.on("end", () => resolve(body));
    req.on("error", reject);
  });
}

Need authority the client must not predict — a policy guard the client deliberately skips, or relational SQL a keyed op can’t express (an owner-gated cascade, a dedup by a non-pk column)? Override just that name next to the spread: { ...sharedApiMutators(mutators, sharedCtx), createIssue: withSpamGuard(mutators.createIssue) }. For those overrides, context-scoped queries (“my issues”), the optional one-shot read route, and the two rejection shapes in full, see the API server.

6 · Run it

One command. concurrently runs your three processes: the pair (via rindle up, which renders your rindle.ncl, supervises the write-master + follower, and folds the whole schema workflow in — apply pending migrations to the master and regenerate shared/schema.gen.ts from the follower on boot and on every migrations/ change), your API server, and the web dev server:

// package.json — `rindle up` renders rindle.ncl and supervises the pair (write-master +
// follower), and owns migrations + schema-gen; the API server and web tier are yours, so a
// plain process runner glues the three together. `rindle exec` derives the app-facing
// read/write/ws bindings from rindle.ncl. (`node server/api.ts` runs TypeScript directly — Node ≥ 22.18.)
{
  "scripts": {
    "dev": "concurrently -k -n rindle,api,web \"pnpm dev:rindle\" \"pnpm dev:api\" \"pnpm dev:web\"",
    "dev:rindle": "rindle up --migrate --gen shared/schema.gen.ts --watch",
    "dev:api": "rindle exec -- node --watch server/api.ts",
    "dev:web": "rindle exec -- vite"
  }
}
pnpm dev

The browser and API server use the stable local fleet edge (7650) for subscriptions and reads; the API server sends writes to the write-master (7611). rindle exec supplies these derived bindings. Bearer tokens are omitted in local dev and remain separate server-only environment configuration in production. A browser never speaks a control plane.

On the very first boot the api and web processes start before shared/schema.gen.ts exists — node --watch fails, waits, and restarts the moment the daemon writes it (Vite recovers the same way). Every boot after that is clean, and adding a migration while pnpm dev runs applies it — and regenerates the schema — on save, so your app’s types track the schema with no extra step.

Point the frontend dev server’s /api at the API server’s port (7700):

// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  server: { proxy: { "/api": "http://127.0.0.1:7700" } },
});

Open two browser windows and watch writes sync live across them: reads resolve locally and instantly; writes apply optimistically and rebase as the API server confirms them.

Go to production

Your app code doesn’t change — only where the data tier lives does. The schema workflow is the same everywhere: apply migrations to the write-master and regenerate the schema from a follower.

  • Rindle Cloud (managed). Provision a managed app in a few minutes (Cloud quickstart). The Connect panel hands you the values your tiers need — Live queries (wss://…) for the browser’s daemon.wsUrl, the Control plane (https://…:8443) for the API server’s read leg, the write-master endpoint for its write leg, and a daemon token (API server only — it never reaches the browser). Migrate the master and regenerate from the follower:

    npx rindle migrate apply --remote                                                        # → the write-master
    npx rindle schema gen --url https://YOUR-APP:8443 --token "$RINDLE_DAEMON_TOKEN" --out shared/schema.gen.ts

    Then swap the endpoints into steps 4 and 5 (RINDLE_REPLICATOR_URL for the write leg, RINDLE_DAEMON_URL for the read leg). See Connect your app for the exact diff.

  • Self-host. @rindle/cli ships the prebuilt binaries; run the pair on a box you operate — a rindle.ncl with followers = 1 is the colocated pair (set auth tokens, expose the follower’s two ports and the master’s write port), and apply migrations with rindle migrate apply --url <write-master> --token …. See run the daemon and deploying & scaling for the read-scaled shapes.

Rules that keep it correct

The contract is view-after-write == fresh-query across all three tiers. A handful of constraints uphold it — break one and the app goes subtly wrong:

  • SQL is the source of truth; the TS schema is generated. Evolve the schema with additive migrations (CREATE TABLE, ADD COLUMN, CREATE INDEX — every table a single PK), apply them to the write-master, and re-run rindle schema gen after each one (in local dev, the rindle up --migrate --gen … --watch inside pnpm dev does the apply-and-regenerate on every migrations/ change). Don’t hand-edit the generated file (except to refine a json<T>() element type).
  • A mutator is one isomorphic body — deterministic and replayable. No Date.now(), no Math.random(), no I/O — the client re-invokes it on every rebase. Generate ids and timestamps at the callsite and pass them in as args; the acting user is ctx.user (injected per tier), never an argument. Add an explicit server entry only for authority the client must not predict.
  • Remote subscriptions must be named. Define each with defineQuery and call the value (issuesPageQuery({ limit })). An ad-hoc app.store.query.issue.where… builder resolves locally only (off already-synced rows) and never opens a server subscription — handy for instant drill-downs, but it won’t pull new data.
  • (name, args) is all that crosses the wire. Client-built ASTs and client-computed effects never become authority. The server parses every mutation’s args through the mutator’s co-located .args schema, and each query’s validate step re-runs on the untrusted args.
  • The daemon token is server-only. It gates the private control plane; the browser only ever holds the lease-gated WebSocket. Keep it in the API server’s secret store.
  • Subscribe to windows, not whole tables. Order + limit, and ratchet the limit up for “load more”. IVM keeps the window — and its countAs — exact as rows enter and leave.
  • Folded (high-frequency) writes must be absorbing. If you reach for app.mutate.<name>.folded(...) for a slider/drag, replaying only the last args must equal replaying all of them — an increment()-style mutator must not be folded, and a mutator that reads (yield tx.row / tx.query) can’t be.

When something goes subtly wrong anyway, work down Troubleshooting — every failure mode there is one of these rules, broken.

Next steps