Skip to content
Guides contents

GuidesSync & optimistic writes

Folded mutations

Coalesce repeated optimistic writes from typing, sliders, and dragging before sending them to the server.

View as Markdown

A folded mutation applies every call as a local prediction, but combines pending calls before sending them to the server. Use it for typing, sliders, or dragging when only the latest value matters.

This guide extends the manual synced-app quickstart. It uses that app’s issue schema, shared mutator registry, and browser client.

Define a mutation that sets the final value

Add this entry to mutators in shared/app-def.ts. The quickstart already defines shared, imports z, and imports the MutationGen type:

renameIssue: shared(
  z.object({ id: z.string(), title: z.string(), updatedAt: z.number() }),
  function* (tx, a): MutationGen {
    yield tx.update("issue", {
      id: a.id,
      title: a.title,
      updatedAt: a.updatedAt,
    });
  },
),

This is an absorbing write: applying only the last arguments has the same effect as applying every call in order. It sets columns from its arguments and does not read state.

Do not fold increments, append operations, or writes whose result depends on intermediate values. The folded path rejects reads through tx.row or tx.query. Server authorization still applies to the mutation that the client sends.

Fold calls from the editor

// src/rename-issue.ts
import { app } from "./rindle-client.ts";

export function previewTitle(id: string, title: string) {
  return app.mutate.renameIssue.folded(
    { key: id, debounceMs: 120, maxWaitMs: 1000 },
    { id, title, updatedAt: Date.now() },
  );
}

const pending = previewTitle("issue-42", "Updated title");
// For example, force the last preview to send when the editor loses focus:
pending.flush();
const mid = await pending.mid;
console.log("Assigned mutation ID", mid);

The fold identity combines the mutator name with key. Use the row’s primary key when each row has an independent edit stream.

Every call updates the local view immediately. A trailing debounce sends the latest arguments after an idle gap. debounceMs defaults to 120 ms. maxWaitMs is optional; without it, continuous input can keep delaying the flush. With the example’s threshold, a call made at least one second into the window flushes it immediately. This check runs on calls, so it is not a timer deadline: if input stops first, the trailing debounce can flush later. A long edit can send multiple server writes.

flush() sends the pending window now. mid resolves when that window receives its wire mutation ID. It does not indicate server acceptance or confirmation. Use the client’s pending and rejection signals to show write status.

Finish a gesture

app.flushFolds() flushes all outstanding windows. The client also hooks page lifecycle events, but a page closing is not a durable delivery guarantee.

A normal call to app.mutate.renameIssue(args) is useful when an explicit final action should be a separate mutation. By default, overlapping ordinary writes flush earlier folds so their order remains meaningful.

Keep undo history at the gesture level. Record the value before the edit and its final value, rather than every preview frame.