Guide · 07

Read from any replica.

Spread queries across replicas, choose between fresh and fast reads, and watch values change live.

Spread reads across replicas

Give the client a list of queryUrls to run queries and watches on every node. Reads stay fresh by default: each one is confirmed with a quorum, so it needs a working majority.

client-replicas.ts
import { FlowerClient } from "@flower-js/sdk";
import type counter from "./counter.ts";

const client = new FlowerClient<typeof counter>("http://127.0.0.1:7101", {
  queryUrls: [
    "http://127.0.0.1:7101",
    "http://127.0.0.1:7102",
    "http://127.0.0.1:7103",
  ],
});
const [visits, clicks] = await Promise.all([
  client.query("counter.get", "visits"),
  client.query("counter.get", "clicks"),
]);
console.log(visits.value.count, clicks.value.count);
  • Queries and new watches rotate through queryUrls. A watch stays on the node it started on; subscribe moves to the next one when it reconnects.
  • Mutations, deploys and call() use the main URL. Any node works; it forwards writes to the leader.
  • The client retries only when you pass retry. It doesn't discover nodes or change consistency.
Query result caching

Flower reuses a query's result while the records, indexes and code it read are unchanged. This never makes a read staler: fresh queries still confirm with a quorum first, and access checks still run on every request. Queries that read ctx.now() are never cached.

  • FLOWER_QUERY_CACHE_BYTES: cache size per logical database. Default 16 MiB; 0 disables it.
  • FLOWER_QUERY_FLIGHT_BYTES: memory for sharing identical concurrent reads. Default 512 KiB; 0 disables it.
  • FLOWER_QUERY_WORKERS: bounds fast cache lookups and access checks. Heavy query work shares FLOWER_PREPARATION_WORKERS with writes and watches.

Allow stale reads (opt in)

If a query can tolerate lag, declare it replica-local. It then reads the node's own data without asking a quorum, and keeps working during a network partition.

Application · optional local read
const getLocal = query("internal.counter.local", {
  args: v.string({ min: 1 }), consistency: "replica-local",
}, (ctx, id) => ctx.get(counters, id) ?? 0);
// Expose it: define({ http: { "counter.local": getLocal } }).

Replica-local reads can be stale, with no bound. Data, code and the list of public methods may all lag. Two calls on different nodes can return revisions that go backwards.

  • The policy lives in the application. HTTP callers can't change it.
  • Only queries can declare it. Leave it out, or use linearizable, for fresh reads.
  • Apps with access checks or managed keys read fresh anyway.
  • A restarted node may need a quorum once before it serves these reads.

Use HTTP/2 from Node.js

Use the HTTP/2 transport to send many requests over one connection per node.

client-h2.ts
import { FlowerClient } from "@flower-js/sdk";
import { createHttp2Transport } from "@flower-js/sdk/http2";
import type counter from "./counter.ts";

const transport = createHttp2Transport({ requestTimeoutMs: 10_000 });
const client = new FlowerClient<typeof counter>("http://127.0.0.1:7101", {
  fetch: transport.fetch,
});
try {
  console.log(await client.mutate("counter.increment", { id: "visits", by: 1 }, {
    requestId: "visit-over-h2-001",
    signal: AbortSignal.timeout(5_000),
  }));
} finally {
  await transport.close();
}
  • Import it from @flower-js/sdk/http2. It works in Node.js only.
  • Options and defaults: requestTimeoutMs (30 s), idleTimeoutMs (30 s), maxRequestBytes (8 MiB), maxResponseBytes (64 MiB), maxSessions (16).
  • For watches, the timeout covers only the response headers. Your signal or transport.close() ends the stream.
  • Close the transport when done; that cancels open streams. It never redirects or downgrades.
  • Cancelling doesn't undo a mutation that already committed. After an uncertain result, retry with the same request ID.

The server port speaks HTTP/1.1 and cleartext HTTP/2 (h2c). To check it with curl:

With an HTTP/2-enabled curl
curl --http2-prior-knowledge -i http://127.0.0.1:7101/health

For TLS, set FLOWER_TLS_CERT_FILE, FLOWER_TLS_KEY_FILE and FLOWER_TLS_CA_FILE. The transport's optional ca option adds trusted roots; certificate checks can't be turned off.

Watch live queries

client.subscribe gives you a query's value now and again every time it changes, and keeps going through disconnects.

One query, kept current
const stop = new AbortController();
for await (const { value, revision, reset } of client.subscribe("counter.get", "visits", {
  signal: stop.signal,
})) {
  if (reset) console.log("fresh snapshot");
  console.log(revision, value.count);
  // Break the loop or call stop.abort() to disconnect.
}

const { value } = await client.waitUntil("counter.get", "visits", (counter) => counter.count >= 10);
  • The first update is the full value. After that the server sends only changes, and the SDK rebuilds the value for you.
  • Updates may skip intermediate values when changes come fast. Use them for current state, not as an event log. Nothing is sent if the value doesn't change.
  • After a disconnect, a stall (no bytes for 45 s; the server sends a keepalive every 15 s) or a transient error, subscribe reconnects with backoff and marks the next value reset: true. Other errors end the loop.
  • Pass monotonic: true to skip values older than one already delivered, such as from a lagging replica.
  • waitUntil resolves with the first value that passes your test, reconnecting as needed.
  • Each subscriber is authorized on its own, and the stream closes if its credentials expire or are revoked.
  • A deploy or principal change ends a stream with WATCH_SCOPE_CHANGED. subscribe reconnects and delivers a fresh snapshot.
  • Fresh watches recheck on a timer as well as on commits: FLOWER_WATCH_REFRESH_MS, default 250 ms.
  • A client that can't accept an update within the send timeout (5 s by default) is disconnected.

client.watch is the single-connection version: it yields { revision, value } and ends at the first disconnect. watchPoll(alias, args, { intervalMs, signal }) polls instead.

Client-side size limits apply to subscribe, watch and watchDeltas. Raise them if your server allows larger results:

  • maxValueBytes: 16 MiB
  • maxEventBytes: 17 MiB
  • maxPatchOperations: 256

The CLI takes the same limits: watch --max-value-bytes 33554432 --max-event-bytes 34603008 --max-patch-operations 512.

Raw deltas with watchDeltas

POST /v1/watch takes {name, args?, credentials?} and returns server-sent events. The first is a snapshot; later ones are RFC 6902 JSON Patch deltas, or a new snapshot when that is cheaper.

watchDeltas gives you those events with a type field. Snapshots carry sequence, revision, value. Patches carry sequence, baseSequence, revision, patch. A patch applies only to the event whose sequence matches its baseSequence. Sequences can start above zero and skip after a reset. A value that depends on time can change without a new revision. Error events carry the method's failure, like HTTP errors.

For error codes, see Handle failures.