Rindle

API index and search · Build metadata

Source snapshot

packages/remote/src/mutation-queue.ts

Source revision 05d0bf2c2e56 · build details
Source revision: 05d0bf2c2e56.
TypeScript input SHA-256: aabe6cfcc4172b870d5e272142958e9ea8d8784c2aa23133156e5a7ee633318e
Generated 2026-09-04T23:58:25.590Z with TypeScript 6.0.3. Public TypeScript checks and declaration emit passed. Package runtime tests are separate.
1// The client-side mutation queue for daemon/serverless deployments. The optimistic engine2// requires mids to arrive at the daemon CONTIGUOUSLY (`mid == lmid + 1`); a websocket gives3// that ordering for free, but the serverless hop (client → API server over HTTP → daemon)4// does not — two parallel fetches can reorder. The queue restores the invariant the cheap5// way: envelopes are sent as in-order batches, and the next batch does not leave until the6// prior one is confirmed (the API server's response means the daemon durably processed it).7//8// Delivery is at-least-once: a failed flush retries the SAME batch with backoff, which is9// safe because the daemon absorbs `mid ≤ lmid` as an idempotent replay. A per-envelope10// REJECTION (the API server's policy saying no) is not retried — the daemon has already11// advanced lmid past it, the authoritative snap-back rides the lmid release, and the reason12// surfaces here through `onRejected` (the wire itself carries no rejection signal).1314import type { MutationEnvelope } from "@rindle/client";1516import type { MutationEnvelopeSender } from "./subscribe.ts";1718/** One envelope's outcome from the API server's mutate endpoint. */19export interface PushOutcome {20  accepted: boolean;21  reason?: string;22}2324export interface QueuedMutationSenderOptions {25  /** Deliver one in-order batch; resolve once the daemon durably processed it (i.e. the26   *  API server awaited its daemon call before responding). Throw to have the SAME batch27   *  retried. Return per-envelope outcomes, or void when everything was accepted. */28  send: (envelopes: MutationEnvelope[]) => Promise<PushOutcome[] | void>;29  /** A policy rejection for one envelope (never retried; the prediction snaps back via the30   *  lmid release — this callback is where the reason reaches the app). */31  onRejected?: (envelope: MutationEnvelope, reason: string) => void;32  /** A failed flush attempt, before its retry. The ONLY surface for a transport/authority33   *  failure: `send` throwing is otherwise absorbed by the retry loop, so a queue built without34   *  this hook fails perfectly silently while head-of-line blocking every later mutation. */35  onError?: (err: unknown, attempt: number) => void;36  /** Backoff before retry `attempt` (1-based). Default: 200ms · 2^(attempt-1), capped at 5s. */37  retryDelayMs?: (attempt: number) => number;38  /** Max envelopes per flush. Default 32. */39  maxBatch?: number;40}4142interface Pending {43  envelope: MutationEnvelope;44  /** Resolves when the envelope's batch is confirmed (accepted OR rejected — both are45   *  durably processed states). */46  resolve: () => void;47}4849const defaultDelay = (attempt: number): number => Math.min(200 * 2 ** (attempt - 1), 5_000);5051const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));5253/** Run an app-supplied observer without letting it break the queue. A throw escaping `flush`54 *  would abandon the loop with envelopes still queued and nothing scheduled to drain them —55 *  the notification hooks must not be able to wedge the write path they report on. */56const notify = (what: string, fn: () => void): void => {57  try {58    fn();59  } catch (err) {60    console.error(`[rindle] mutation queue ${what} handler threw:`, err);61  }62};6364/** A `MutationEnvelopeSender` (the `pushMutation` option of `RemoteOptimisticSource`) that65 *  queues envelopes and delivers them as confirmed, in-order batches. */66export function createQueuedMutationSender(opts: QueuedMutationSenderOptions): MutationEnvelopeSender {67  const queue: Pending[] = [];68  const maxBatch = Math.max(1, opts.maxBatch ?? 32);69  const delay = opts.retryDelayMs ?? defaultDelay;70  let flushing = false;7172  const flush = async (): Promise<void> => {73    if (flushing) return;74    flushing = true;75    try {76      while (queue.length > 0) {77        const batch = queue.slice(0, maxBatch);78        let outcomes: PushOutcome[] | void;79        for (let attempt = 1; ; attempt++) {80          try {81            outcomes = await opts.send(batch.map((p) => p.envelope));82            break;83          } catch (err) {84            notify("onError", () => opts.onError?.(err, attempt));85            await sleep(delay(attempt));86          }87        }88        queue.splice(0, batch.length);89        batch.forEach((pending, i) => {90          const outcome = Array.isArray(outcomes) ? outcomes[i] : undefined;91          if (outcome && !outcome.accepted) {92            const reason = outcome.reason ?? "mutation rejected";93            notify("onRejected", () => opts.onRejected?.(pending.envelope, reason));94          }95          pending.resolve();96        });97      }98    } finally {99      flushing = false;100    }101    // Envelopes enqueued during the final await: a new flush owns them.102    if (queue.length > 0) void flush();103  };104105  return (envelope) =>106    new Promise<void>((resolve) => {107      queue.push({ envelope, resolve });108      void flush();109    });110}111