Sync & Invalidations

Furin Sync adds three guarantees around Elysia mutation routes:

  1. an Idempotency-Key identifies retries of the same principal and request;
  2. a successful bounded response can be replayed without running the handler again;
  3. committed cache invalidations enter an ordered change journal consumed by other browsers.

Use it for repeatable application mutations whose response is small and whose clients benefit from automatic cross-tab refresh. It is not a transaction manager for arbitrary external side effects.

Configure One Runtime

Bind the ORM once in the adapter configuration, then pass the same runtime object to the page plugin and mutation plugin:

src/sync.ts
import { drizzleSyncAdapter } from "@teyik0/furin/sync/drizzle";
import { migrateSqliteSync } from "@teyik0/furin/sync/sqlite";
import { requireUser } from "./auth";
import { db } from "./db";

// db is a Drizzle bun:sqlite database. Run this migration at deployment.
migrateSqliteSync(db.$client);

export const sync = {
  adapter: drizzleSyncAdapter({ db, namespace: "my-app" }),
  principal: ({ request }) => requireUser(request).id,
};

namespace separates applications sharing the same database. principal must return a stable, non-empty identity; it scopes idempotency keys so two users can safely use the same client-generated key.

Authenticate the request inside principal, for example by verifying a signed session as requireUser does above. The same callback authorizes journal recovery at the change endpoint. Never return an unverified header or a client-supplied user ID: hashing a principal separates journal entries but does not authenticate a request.

Principal Secret

Furin sends a hash of the principal to the browser (the session of identified reads, used to drop a previous user's cached data) and stores it in the journal's principal_hash. Without a secret this is plain SHA-256, so an email or sequential ID can be recovered by hashing guesses. Set secret to key it with HMAC-SHA256:

ts
export const sync = {
  adapter: drizzleSyncAdapter({ db, namespace: "my-app" }),
  principal: ({ request }) => requireUser(request).id,
  secret: process.env.FURIN_SYNC_SECRET, // e.g. `openssl rand -base64 32`
};
  • The secret must be at least 32 characters; shorter values throw at startup. Never prefix the variable with FURIN_PUBLIC_.
  • Use the same secret on every node. Browsers compare session values across responses, and a node with another secret looks like a different user and clears that browser's queries.
  • Setting or rotating the secret is safe. Journal entries hashed with the previous value no longer match, so recovery takes the full-refresh path described in Browser Catch-Up, and browsers refetch their queries once.
  • Production logs a warning once per adapter when secret is missing.

This follows the same model as BETTER_AUTH_SECRET (better-auth), AUTH_SECRET (Auth.js, at least 32 characters), and Next.js's NEXT_SERVER_ACTIONS_ENCRYPTION_KEY. Each is a server-only key that must match across instances. You can reuse your authentication library's secret value, but a dedicated one can be rotated on its own.

src/server.ts
import { Elysia } from "elysia";
import { furin } from "@teyik0/furin";
import { api } from "./api";
import { sync } from "./sync";

new Elysia()
  .use(await furin({ pagesDir: "./src/pages", sync }))
  .use(api)
  .listen(3000);

Passing sync to furin() mounts the change endpoints and injects the browser event transport automatically.

The final application mounting furin({ sync }) also binds the effective Elysia response guards, models and normalization used inside native transactions. With standalone Elysia, install furinSync(sync) on the final application as well as any separately typed route modules. An unbound parent application is rejected before the business callback runs; Furin never commits using only a child's response schema. If you remount a complete application in another root and call handle() directly, bind furinSync(sync) on that final root too.

The optional path property changes the durable endpoint base from /_furin/sync; for example, path: "/_internal/sync" exposes catch-up at /_internal/sync/changes. Browser notifications use /_furin/events under the Furin instance prefix.

Declare Synced Mutations

Install furinSync(sync) before the routes it owns:

src/api/index.ts
import { Elysia, t } from "elysia";
import { furinSync } from "@teyik0/furin";
import { sync } from "../sync";
import { posts } from "../db/schema";

export const api = new Elysia({ prefix: "/api" })
  .use(furinSync(sync))
  .post(
    "/posts",
    { body: t.Object({ title: t.String() }) },
    ({ body, mutation, invalidate }) => mutation(tx => {
      invalidate({ tags: ["posts"] });
      return tx.insert(posts).values(body).returning().get();
    })
  );

All non-GET/HEAD/OPTIONS routes covered by the plugin participate in sync. A successful mutation invalidates the GET at its own URL; invalidate() adds GET URLs, cache tags or pages. With an ORM adapter, return mutation(callback) and perform all dependent reads and writes through its native, fully inferred tx. Furin reserves the idempotency key before running the handler. The native transaction locks and rechecks that reservation, then commits business writes, the replayable response and journal together. Cache invalidation and notifications run after commit; the cache itself is outside the database transaction.

Drizzle currently supports bun:sqlite and bun-sql PostgreSQL. SQLite callbacks and response validators must be synchronous; PostgreSQL callbacks may be async. A failed business result (4xx/5xx), exception, response-schema validation, serialization, lease check or journal write rolls back the entire transaction. Atomic responses must be bounded JSON, text, or bodyless Response values such as redirects. Async PostgreSQL transactions also accept body-bearing Response values with an explicit Content-Length up to 1 MiB; Bun SQLite transactions require bodyless Response values.

Once PostgreSQL has checked and locked the reservation, that transaction retains ownership until commit or rollback. Another request cannot take over an expired lease while the transaction holds its lock. The ORM's own transaction timeout still applies.

The callback resolves to its real native value on the first execution, preserving Prisma dates and Drizzle types. A replay or idempotency conflict short-circuits the HTTP handler; it does not fabricate a native ORM value from JSON. Put the complete response construction in the callback and return its result directly. Response-changing hooks and work after the callback are outside the transaction. Calls to the global database client and external side effects do not become transactional automatically.

Prisma 7 with PostgreSQL

ts
import { prismaSyncAdapter } from "@teyik0/furin/sync/prisma";
import { PollingSyncNotifier } from "@teyik0/furin/sync";
import { prisma } from "./db";

const adapter = prismaSyncAdapter({ client: prisma, namespace: "my-app" });
export const sync = {
  adapter,
  // Distributed production runtimes require an explicit notifier.
  notifier: new PollingSyncNotifier(adapter, 250),
  principal: ({ request }) => requireUser(request).id,
};

// Inside an Elysia handler using furinSync(sync):
return mutation(tx =>
  tx.post.create({ data: body })
);

Run the existing @teyik0/furin/sync/postgres/migration.sql migration on that same PostgreSQL database. Prisma 7 keeps its generated transaction client and model inference; the adapter does not require custom Prisma models for the journal. Prisma 8 and other Prisma database providers are not supported by this adapter. Drizzle PostgreSQL uses the same migration and also requires an explicit notifier in production.

Set idempotent: false for routes whose request or response cannot be replayed safely:

ts
.post("/uploads", { idempotent: false }, uploadFile)

Such a route ignores Idempotency-Key, stores no response to replay and cannot use mutation(): every request runs. Its invalidations still apply once it succeeds: caches are purged, the caller receives them, and they are journaled and broadcast. Their journal entries carry no principal, so other browsers refresh all their live reads rather than only the affected ones. Typical exclusions include uploads, downloads, streams, payment initiation, and handlers with deliberately non-repeatable external effects.

Identify Reads By URL

A GET is identified by its URL: its route pattern and params (/boards/:boardId/cards with { boardId: "alpha" }). The query string is ignored. Nothing is declared; keep database access inside the Elysia handler:

ts
new Elysia().use(furinSync(sync))
  .get("/boards/:boardId/cards", ({ params }) => listCards(params.boardId))
  .post("/boards/:boardId/cards", {
    body: t.Object({ title: t.String() }),
  }, ({ params, body, mutation }) =>
    mutation(tx => createCard(tx, params.boardId, body.title))
  );

The server stamps every successful data GET with its URL identity and the principal's session in the x-furin-query response header. A successful mutation invalidates the GET at its own URL: POST /boards/alpha/cards refreshes GET /boards/alpha/cards, PATCH /cards/c1 refreshes GET /cards/c1. A rollback publishes no invalidation.

To invalidate another resource, call invalidate(pattern, params?) from the handler context. Params are typed from the pattern; an omitted param matches every value of it (invalidate("/boards/:boardId") refreshes every board). invalidate({ tags: ["posts"] }) targets cached paths that declared those tags, and invalidate({ path: "/blog", type: "layout" }) a page (type: "page", one exact path) or a layout (the path and its descendants). Calls are additive, share the mutation's headers and journal entry, and are discarded unless the mutation succeeds or commits:

ts
.delete("/cards/:id", ({ params, mutation, invalidate, problem }) =>
  mutation((tx) => {
    const card = getCard(params.id, tx);
    if (!card) return problem("Not Found", { detail: "Card not found" });
    deleteCard(tx, params.id);
    invalidate("/boards/:boardId", { boardId: card.boardId });
    return { ok: true };
  })
)

In a mutation() route, call invalidate() before the callback returns: a later call throws in development and is ignored in production. Calling invalidate() while handling a GET throws.

Register the application once to type the patterns. They are the app's GET routes, prefix included; unknown routes, routes without a GET and wrong param names are type errors:

src/api/index.ts
declare module "@teyik0/furin" {
  interface Register { app: typeof api }
}

Without a registered app, invalidate() accepts any path and params. Calls from hooks, such as a guard()'s beforeHandle, add up with the handler's.

Outside a request (a cron job, a webhook consumer), call invalidateSync() with the same sync options and any target invalidate() takes. It purges the pages built from it, then journals and broadcasts the invalidation, as a mutation would:

ts
import { invalidateSync } from "@teyik0/furin";
import { sync } from "./sync";

await invalidateSync(sync, "/api/boards/:boardId", { boardId });
await invalidateSync(sync, { tags: ["board-count"] });
await invalidateSync(sync, { path: "/board", type: "layout" });

The options select the runtime: the adapter journals the change and the notifier wakes connected browsers, including those served by other processes sharing the adapter. Its journal entry carries no principal, so browsers refresh all their live reads.

Every GET has a live query, .get.live(options?), on a client using the furinSync Eden plugin. Await it in a loader to track the read:

ts
export const route = defineRoute("/board/[boardId]")
  .config({ mode: "ssr" })
  .loader(async ({ params }) => {
    const { data, error } = await api.boards({ boardId: params.boardId }).cards.get.live();
    if (error) throw error;
    return { initialCards: data };
  });

No matching loader tag is needed. Furin registers the dependency of every completed live read, so cache tags and sync refreshes target the route. A plain await api.…get() stays an untracked Eden call. Keep credentials on the server-side Eden client request: tracking does not forward the page request's authentication headers automatically. Use SSR or request loaders for personalized reads; public SSG/ISR loaders must remain public.

A read's response body is sent to the browser, so useQuery and cache.update start from it without a GET, only when the loader output already exposes all of it by object identity: either the whole response (return { initialCards: data }) or every top-level field as an object or array (return { board: data.board, cards: data.cards }). Otherwise only the dependency travels and the browser fetches the GET when it needs it. A loader that reads { displayName, passwordResetToken } and returns displayName, or returns board and cards but not a scalar total, never sends the rest of the response.

Update Loader Props With One GET Projection

Keep objects and arrays from the GET in the loader result:

tsx
.loader(async ({ params }) => {
  const { data, error } = await api.boards({ boardId: params.boardId }).get.live();
  if (error) throw error;
  return { board: data.board, cards: data.cards };
})
.page(({ cards }) => {
  const done = cards.filter(card => card.column === "done").length;
  return <Board cards={cards} done={done} />;
})

One projection updates the shared GET cache and these loader props:

ts
optimistic(cache) {
  cache.update(api.boards({ boardId }).get.live(), board => ({
    ...board,
    cards: board.cards.map(card =>
      card.id === cardId ? { ...card, column: "done" } : card
    ),
  }));
}

No useQuery, route target or second optimistic transform is required. Furin records object-reference paths before serialization, projects props from the shared snapshot and retains the confirmed loader values for rollback. Nested object properties and whole arrays are supported, including cached public loaders. Derive counts and filters in the page: copied objects, array-element selections and computed scalars do not carry automatic bindings. The explicit route snapshot API below remains available for those transformations.

The same mapping works for requestLoader and defer() fields after they resolve:

tsx
.requestLoader(({ params }) => ({
  privateCards: api.boards({ boardId: params.boardId }).get.live().then(({ data, error }) => {
    if (error) throw error;
    return data.cards;
  }),
}))
.page(({ privateCards }) => (
  <Suspense fallback={<p>Loading cards…</p>}>
    <Await resolve={privateCards}>{cards => <Board cards={cards} />}</Await>
  </Suspense>
))

GET dependencies and bindings travel with each resolved field, so a slow sibling does not delay them. The prop stays a promise compatible with Await and React use(). A cache.update of the board GET updates these cards and rolls back on failure. Sync invalidations also refresh the affected route and expire its prefetched snapshot. Private reads remain request scoped and are not stored in the public PPR shell; configure the Eden request credentials explicitly as described above.

Optional Query Subscriptions

tsx
import { useQuery } from "@teyik0/furin/client";

function Cards({ boardId }: { boardId: string }) {
  const { data, error, isFetching } = useQuery(api.boards({ boardId }).cards.get.live());
  if (error) return <p>Could not load cards.</p>;
  return <ul>{data?.map(card => <li key={card.id}>{card.title}</li>)}</ul>;
}

function DoneCount({ boardId }: { boardId: string }) {
  const { data } = useQuery(api.boards({ boardId }).cards.get.live(), {
    select: cards => cards.filter(card => card.column === "done").length,
  });
  return <span>{data ?? 0} done</span>;
}

Consumers of the same GET, query arguments and request options share one cache entry and fetch. Completed loader reads with only query options hydrate the default cache without an initial browser GET. Reads with explicit request options, such as authorization headers, retain their loader bindings and PPR metadata under an isolated, opaque seed identity; browser consumers fetch their own variant because the server's credentials cannot be assumed to match. select derives a value from the shared data without changing its identity. The hook returns data, error and isFetching. Awaiting a live query, or calling await api.…get() directly, still returns Eden's original result and performs a request.

Query arguments remain inferred from the GET schema and are passed to .live():

tsx
useQuery(api.cards.get.live({ query: { boardId, column: "done" } }), {
  select: cards => cards.length,
});

An invalidation refreshes every cached query variant of that URL, whatever its query string. Other params remain separate unless the invalidation omits them. Per-call authorization and other request options isolate cache entries. Client-wide defaults still belong to that client's session: create a fresh client when authentication changes and remount the authenticated router (or perform a full navigation). Response session identities also clear data from a previous principal and reject its late responses.

Mutation State In React

useMutation adds React state to an existing mutation without changing its arguments:

tsx
import { useMutation } from "@teyik0/furin/client";
import { useRouter } from "@teyik0/furin/link";

function DeleteCard({ cardId }: { cardId: string }) {
  const router = useRouter();
  const remove = useMutation(api.cards({ id: cardId }).delete, {
    onSuccess: async () => {
      await router.navigate("/boards");
    },
  });

  return (
    <>
      <button disabled={remove.isPending} onClick={() => remove.mutate()}>
        Delete card
      </button>
      {remove.error ? <p>Could not delete the card.</p> : null}
    </>
  );
}

The hook returns data, error, isPending, mutate, mutateAsync and reset. mutate returns void and reports failures through error and the optional onError callback. mutateAsync resolves with the response's data, or rejects with the same error exposed to onError. Every exposed error has Eden's { status, value } shape, with value.detail guaranteed to be a string at runtime and in TypeScript.

An API error whose object payload already contains a string detail remains unchanged. Otherwise the hook creates an EdenFetchError, keeping the HTTP status and the inferred fields of object payloads while adding detail. A nonempty text body becomes the detail; empty, null, array or other primitive bodies use "Mutation failed". The original error is retained in cause, without mutating it.

Network errors and other exceptions, including failures in onSuccess and Eden's synthetic errors without an HTTP response, become an EdenFetchError with status: 0 and the exception's message in value.detail (or "Mutation failed" for non-Error exceptions). Status 0 is a client-side sentinel, not an HTTP response code. This normalization belongs to useMutation; direct Eden calls retain their native response contract. There is one error callback, with no separate native Error branch:

tsx
const save = useMutation(api.cards({ id: cardId }).patch, {
  onError(error) {
    setErrorMessage(error.value.detail);
    // error.status === 0 distinguishes client-side exceptions when needed.
  },
});

Object payloads retain their endpoint-specific fields; use error.status to narrow a specific response when needed. The hook guarantees the detail field, not runtime validation of every other field against the server's TypeScript schema. Normalized messages are not localized or tailored to a particular UI. mutateAsync also rejects if an onError callback throws.

Calls run in parallel. isPending stays true until every call and its awaited onSuccess or onError callback finishes. Only the most recently started call publishes data or error; callbacks still run for each call. Disable the relevant controls while pending if the UI should prevent repeated submissions. The hook does not deduplicate calls or provide a shared mutation lock. reset() clears the displayed result and prevents earlier in-flight calls from publishing it again, but does not cancel those requests or their callbacks.

Eden's body and options stay inferred, including optimistic updates. The hook can also own the projection, and it receives the mutate() arguments:

tsx
const save = useMutation(api.cards({ id: cardId }).patch, {
  retry: 2,
  optimistic(cache, { title }) {
    cache.update(api.cards({ id: cardId }).get.live(), card => ({ ...card, title }));
  },
});

save.mutate({ title });

Each mutate() call generates one Idempotency-Key. retry adds attempts only after 409 FURIN_MUTATION_IN_PROGRESS, and every attempt reuses that key and one optimistic contribution (see Retry). Given an Eden method, the hook always passes an options object, so the plugin attaches the key. Given a function, the key attaches only if the function calls an Eden method synchronously with an options object, for example (title) => api.cards({ id }).patch({ title }, {}). Otherwise that call is not retried and development builds warn.

External APIs

The hook does not require an Eden client. Any asynchronous function returning { data, error } works, with error: null indicating success:

tsx
async function deleteExternalCard(cardId: string) {
  const response = await fetch(`/external-api/cards/${encodeURIComponent(cardId)}`, {
    method: "DELETE",
  });
  if (!response.ok) {
    return { data: null, error: new Error("Could not delete the card") };
  }
  return { data: null, error: null };
}

const remove = useMutation(deleteExternalCard);
// remove.mutate(cardId)

This supplies mutation state and callbacks, not Furin Sync integration: an external API needs its own cache refresh strategy unless it implements the Sync protocol. Furin's page server still uses Elysia; the business API does not have to. A static export can be hosted without an Elysia server.

Reuse A Mutation And Its Optimism

src/mutations/cards.ts
export function completeCard(boardId: string, cardId: string) {
  return api.cards({ id: cardId }).patch({ column: "done" }, {
    optimistic(cache) {
      cache.update(api.boards({ boardId }).cards.get.live(), cards =>
        cards.map(card => card.id === cardId ? { ...card, column: "done" } : card)
      );
    },
  });
}

// Any button, menu or keyboard shortcut:
await completeCard(boardId, cardId);

The list and its selected count update together. Use an ordinary application function to centralize the API call and its projection. A cache miss is a no-op. A definitive rejection removes only that mutation's contribution; success retains it until a fresh GET confirms the view. Reads racing pending writes cannot apply an insert or increment twice. Transforms must be pure; generate temporary IDs before the call. Awaiting the mutation waits for its Eden response, not the final query/loader refresh.

For a filtered GET, target the same arguments:

ts
cache.update(
  api.cards.get.live({ query: { boardId, column: "done" } }),
  cards => cards.filter(card => card.id !== cardId)
);

Different endpoints remain different representations. Update each loaded representation explicitly when both should change immediately:

ts
optimistic(cache) {
  cache.update(api.cards({ id: cardId }).get.live(), card => ({ ...card, title }));
  cache.update(api.boards({ boardId }).cards.get.live(), cards =>
    cards.map(card => card.id === cardId ? { ...card, title } : card)
  );
}

Furin does not normalize entities or execute relational live queries. Tags remain the lower-level mechanism for non-Eden reads and writes outside the mutation plugin. Shared public page caches only store a render after its discovered query dependencies were covered by its cache lease; the first dependency-discovery render may be served with no-store.

Call A Mutation

Register the furinSync Eden plugin on an ordinary Eden client. Mutations keep their arguments and typed { data, error } results:

ts
import { treaty } from "@elysia/eden";
import { furinSync } from "@teyik0/furin/client";
import type { Api } from "./server";

export const api = treaty<Api>(origin, { headers: auth }).use(furinSync).api;
// On the server (isomorphic code): treaty(serverApi).use(furinSync).api

const { data, error } = await api.posts.post({ title: "Hello" });

A plain call stays a plain Eden call. Without a client key, the server generates one: the mutation still runs transactionally, invalidates and broadcasts, but a resend is not deduplicated. A call that passes an options object gets an automatic Idempotency-Key and the typed optimistic option. Explicit keys are respected, including Eden header callbacks and fetch.headers. Each call gets a new key, even when calls share an options object, and the plugin never alters that object. x-furin-queries invalidations in a mutation response refresh the client's live queries whether or not options were passed. Never reuse an explicit key across sessions or principals. Reads and subscriptions keep Eden behavior. The plugin requires Eden 2.0.0-beta.5 or later.

All Eden configuration (fetcher, headers, interceptors, date parsing and error behavior) stays on treaty(). Retries belong to useMutation.

Existing Route Snapshot Targets

Update the loader snapshot already rendered by the router:

tsx
const { error } = await api.cards({ id: cardId }).patch(
  { column: "done" },
  {
    optimistic(cache) {
      cache.update("/board/:boardId", (loader) => ({
        ...loader,
        initialCards: loader.initialCards.map((card) =>
          card.id === cardId ? { ...card, column: "done" } : card
        ),
      }));
    },
  }
);

The page continues rendering its initialCards loader prop. No copied useState(initialData), setter, rollback callback, or manual synchronization is needed. The generated RouteMap offers route completion and infers loader fields from the target. A route pattern updates the active URL, including its path and query parameters. The transform returns complete loader data for that route; cache.update() and optimistic() return void. Generate furin-env.d.ts before using typed optimistic targets.

Only the active loaded snapshot is projected. A cache miss is a no-op. To target an exact query snapshot rather than the active route, use a typed URL target:

ts
cache.update({ path: "/posts", search: { category: "news" } }, (loader) => ({
  ...loader,
  posts: loader.posts.filter((post) => post.id !== deletedId),
}));

Transforms must be pure: Furin reapplies them over confirmed data. Create temporary IDs and timestamps before the call. Each mutation owns its contribution. A definitive business error removes only that contribution. A successful response keeps its contribution until fresh loaders replace it in the same router publication. Optimistic mutations revalidate automatically even with autoRefresh: false. Loaders must read committed writes; Furin cannot establish that guarantee for an eventually consistent replica or external service.

To prevent inserts or increments from being applied twice, revalidation of a projected snapshot waits for its local requests to settle. Navigation to other snapshots stays available. Concurrent writes to the same entity still require an application policy such as version checks or serialization.

Retry

Retry is a useMutation option. retry: 2 means two additional attempts, three requests maximum. The default is 0:

ts
const complete = useMutation(api.cards({ id: cardId }).patch, { retry: 2 });
complete.mutate({ column: "done" });

Only 409 FURIN_MUTATION_IN_PROGRESS is automatically retried, with the same key and one optimistic contribution. Furin respects Retry-After; otherwise it uses exponential delays. Cancellation stops waiting. Business errors, payload mismatch, lost leases, server errors, and network failures are never blindly retried.

A missing response does not prove the domain write failed. After an ambiguous outcome, the optimistic view remains until loader revalidation reconciles it. The API returns the Eden result; revalidation is automatic and its failures are reported by the router separately. The client conservatively keeps this retry policy even when an ORM adapter makes the server write atomic.

Replay Semantics

The adapter keys a mutation by principal, method, pathname, and idempotency key, and fingerprints its payload.

SituationResult
First requestAcquires a lease and runs the handler
Same key and same request after successReplays the stored response
Same key while the first request is running409 FURIN_MUTATION_IN_PROGRESS
Same key with a different payload409 FURIN_IDEMPOTENCY_MISMATCH
Missing keyRuns with a server-generated key; not deduplicated
Invalid key400 FURIN_INVALID_IDEMPOTENCY_KEY
Unsupported request body415
Lost mutation lease before commit503

Only successful 2xx and 3xx results are committed. Stored bodies are limited to 1 MiB; hop-by-hop, authentication, and cookie headers are excluded from replay. ORM adapters roll back unreplayable results before commit. A concurrent request for a reserved key receives 409 FURIN_MUTATION_IN_PROGRESS; retrying after the first request completes replays its response.

The standalone sqliteSyncAdapter, postgresSyncAdapter and redisSyncAdapter retain the separate handler/receipt lifecycle. They atomically store the receipt and journal together, but cannot include writes through an independent domain connection. Their bounded Response support requires a valid Content-Length; an unreplayable successful result is stored as a safe framework error. Use ORM adapters plus mutation(tx => ...) for atomic domain writes. Furin cannot make a third-party API call exactly once.

Browser Catch-Up

After a commit, the notifier wakes connected browsers with the latest cursor. The generated client then reads ordered pages from the /_furin/sync/changes recovery endpoint, invalidates prefetched routes, and refreshes the active route when affected.

Built-in adapters return paginated changes attributable to the current principal. If recovery crosses another principal's changes, older entries without principal provenance, or a custom adapter without that provenance, Furin invalidates all cached queries and prefetched routes instead of exposing journal scopes. With autoRefresh: true, it also reloads the active route's loaders. With autoRefresh: false, active loaders refresh on the next navigation or an explicit router refresh. The public adapter interface stays unchanged.

Bursts of commits are coalesced so one busy writer cannot make every browser reload continuously. Each server process wakes its browsers at most once per 100 ms, always with the latest cursor. Each browser runs a reset-driven full refresh at most once per second. Both limits fire on the trailing edge, so the final state is always fetched.

The wake-up transport is best-effort. Correctness comes from the durable adapter journal and cursor catch-up, so a missed browser event or notifier outage delays discovery but does not erase committed changes.

Furin multiplexes sync, development diagnostics, and DevTools notifications over one versioned /_furin/events connection per page on Bun servers. The Vercel adapter uses Server-Sent Events for sync notifications through its HTTP handler. The browser reconnects when the event stream closes and catches up through the durable journal. Application code should use the furinSync Eden plugin and the router contract rather than either internal endpoint directly.

Adapter Choice

DeploymentAdapter
Local tests or disposable development processSQLite :memory:
One durable hostFile-backed SQLite
Multiple replicas with PostgreSQLPostgreSQL (recommended)
Redis as the durable sync storeRedis
PostgreSQL durability with faster wake-upsPostgreSQL plus Redis notifier

Production rejects process-local adapters such as SQLite :memory:.

PostgreSQL

Apply the bundled idempotent schema bootstrap through the CLI or your migration system:

Run it when upgrading an existing database too: the journal's nullable principal_hash column is added without changing existing entries. Those entries use the full-refresh recovery path.

sh
FURIN_SYNC_POSTGRES_URL=postgres://... \
  bunx --package @teyik0/furin furin-sync-postgres-migrate
ts
import { SQL } from "bun";
import { postgresSyncAdapter, postgresSyncNotifier } from "@teyik0/furin/sync/postgres";

const sync = {
  adapter: postgresSyncAdapter({
    namespace: "my-app",
    sql: new SQL(databaseUrl),
  }),
  notifier: postgresSyncNotifier({
    namespace: "my-app",
    sql: new SQL(directDatabaseUrl),
  }),
  principal: ({ request }) => requireUser(request).id,
};

Use a direct PostgreSQL connection for the notifier's LISTEN; a transaction pooler may drop immediate notifications. Furin checks the durable cursor every 15 seconds to recover missed wake-ups. The adapter's SQL connection may use a pooler.

Redis Notifier

In development, Furin polls the adapter cursor when no notifier is configured. Production with a distributed adapter requires an explicit notifier. Redis is another option for immediate wake-ups across replicas:

ts
import { RedisClient, SQL } from "bun";
import { postgresSyncAdapter } from "@teyik0/furin/sync/postgres";
import { redisSyncNotifier } from "@teyik0/furin/sync/redis";

const sync = {
  adapter: postgresSyncAdapter({ namespace: "my-app", sql: new SQL(databaseUrl) }),
  notifier: redisSyncNotifier({
    client: new RedisClient(redisUrl),
    namespace: "my-app",
  }),
  principal: ({ request }) => requireUser(request).id,
};

The notifier carries cursors only. The adapter remains the source of truth.

Lower-Level Alternative

Use furinInvalidate() for the same handler invalidate() without idempotency or a change journal: a successful mutation purges the server caches built from its targets and its own URL, and only the initiating response reports them (X-Furin-Revalidate, x-furin-queries). Use Furin Sync when retries and cross-client catch-up are part of the mutation contract. See Caching for the cache layers themselves.

Comments