Skip to content
renkudocs
GuidesLocal first

Local first

renku/local keeps bounded complete scopes or projected windows in OPFS SQLite. renku/data/sync validates and sequences changes in a SQLite Durable Object. Your app supplies authorization, scope and transport. Open the working showcase to try offline editing, reload recovery and two-replica conflicts.

On this page
Open the offline task board and conflict playground →

The /local-first showcase contains a task board and two independent replicas. Add tasks, disconnect, reload, reconnect, and inspect retained conflicts. Pause sync rehearses offline edits without disabling your network.

Import defineSchema, field and table from renku/data/schema, and defineReplica from renku/local/schema. Pass {schema, views:{name:{sql:'SELECT ...', tables:['tasks']}}}.

Shared application schema modules declare 'use client' so the browser boundary is explicit; the same pure descriptor can be imported on the server. View SQL is trusted application code, begins with SELECT and has no semicolons.

Materialized tables rebuild when their declared sources change, inside the same local transaction. Declare every source table, including joins. This release uses affected-view recomputation rather than a generic IVM engine.

src/local-model.ts
"use client";
import { defineSchema, field, table } from "renku/data/schema";
import { defineReplica } from "renku/local/schema";
export const taskReplica = defineReplica({
  schema: defineSchema({
    name: "docsLocalTasks",
    tables: {
      tasks: table({
        fields: {
          id: field.string(),
          title: field.string(),
          done: field.boolean(),
          minutes: field.integer(),
        },
        primaryKey: "id",
        indexes: { byDone: ["done"] },
      }),
    },
  }),
  views: {
    totals: {
      tables: ["tasks"],
      sql: "SELECT COUNT(*) AS total,COALESCE(SUM(CASE WHEN done=0 THEN 1 ELSE 0 END),0) AS remaining,COALESCE(SUM(CASE WHEN done=0 THEN minutes ELSE 0 END),0) AS minutes FROM tasks",
    },
  },
});

Enable localFirst:true in renku.config.ts. Add the public app route to prerender and pwa.prepare. Supply the indicated PNG icons and offline TSRX document as described in the offline-app guide.

The build packages content-addressed SQLite 3.54 and dedicated-worker assets and includes them in the PWA generation. No CDN, cross-origin isolation or SharedArrayBuffer is required by the chosen SAH-pool VFS.

New workers wait for normal PWA lifecycle until an explicit Update app action or an RPC build mismatch triggers coordinated activation. Incompatible schemas preserve the existing database and fail explicitly.

renku.config.ts
import { bindings, defineConfig } from "renku";
export default defineConfig({
  localFirst: true,
  sync: "./src/local-handler.ts",
  worker: {
    name: "local-demo", compatibilityDate: "2026-10-09",
    compatibilityFlags: ["nodejs_compat"],
    env: { ASSETS: bindings.assets() },
    exports: { LocalTasks: { type: "durable-object", storage: "sqlite" } },
  },
  workerExports: { LocalTasks: "./src/local-object.ts" },
  // Include a document with the generated stylesheet and runtime script.
  routes: [{ layout: "./src/document.tsrx", children: [
    { path: "local-first", page: "./src/page.tsrx" },
  ] }],
  prerender: ["/local-first"],
  pwa: {
    id: "/local-demo", name: "Local demo", shortName: "Local",
    startUrl: "/local-first", themeColor: "#047857", backgroundColor: "#fafbfb",
    prepare: ["/local-first"],
    // Supply app-owned PNGs under public/ and an offline TSRX document.
    icons: [
      { src: "/icon-192.png", size: 192, purpose: "any" },
      { src: "/icon-512.png", size: 512, purpose: "any maskable" },
    ],
    fallback: "./src/offline.tsrx",
  },
});

Call openReplica({principal, name, scope, definition, url}) from onMount, and close it in onCleanup. name distinguishes local replicas; scope must identify the authorized server partition and principal. subscribe publishes {rows, views, intents, head, selection, sync, error}; write([{table,type:'create',data}]) or update/delete commits locally before resolving.

Sync uses a same-origin WebSocket URL. Server notifications trigger catch-up; disconnected sockets retry with bounded backoff. Idle replicas do not poll. setPaused controls uploads, sync requests an exchange, and discard only removes rejected or blocked intent from the active queue.

A failed transport never deletes queued work. Pass write(edits, {id}) with a stable caller-generated ID when retrying an uncertain local-worker acknowledgement; reuse requires the exact same edits.

src/local-counter.tsrx
"use client";
import { state, action, onMount, onCleanup } from "renku/client";
import { openReplica, type Replica } from "renku/local";
import { taskReplica } from "./local-model.ts";

// The app supplies the same persisted 64-hex demo capability on every mount.
export default function LocalCounter({ capability }: { capability: string }) {
  const remaining = state(0), error = state("");
  const publish = action((n: number) => remaining.set(n));
  const fail = action(() => error.set("Could not save locally. Please retry."));
  let replica: Replica<typeof taskReplica.schema> | undefined, alive = true;
  onMount(() => { void (async () => {
    try {
      const opened = await openReplica({ name: "board", scope: "demo:" + capability,
        definition: taskReplica, url: "/_renku/sync?capability=" + capability });
      if (!alive) { opened.close(); return; }
      replica = opened;
      opened.subscribe(value => publish(Number(value.views.totals?.[0]?.remaining ?? 0)));
    } catch { fail(); }
  })(); });
  onCleanup(() => { alive = false; replica?.close(); });
  async function add() {
    if (!replica) return;
    try { await replica.write([{ table: "tasks", type: "create", data: {
      id: crypto.randomUUID(), title: "A new idea", done: false, minutes: 15,
    } }]); } catch { fail(); }
  }
  return @{<section><button onClick={add}>Add task</button>
    <output>{remaining.get()} tasks remaining</output><p>{error.get()}</p></section>};
}

Inside a SQLite Durable Object, call openSyncSocket(ctx, context => ({definition,scope,authorize,finalize,validate})), then delegate fetch, webSocketMessage, webSocketClose and webSocketError.

The context is saved in the socket attachment so the object can hibernate; the options callback may be async to check remote membership before every exchange. Upgrade responses must travel through native Durable Object fetch.

Every exchange rechecks authorize(context); validate receives read-only table methods and can return an approved domain rejection code.

The optional synchronous finalize(tables,command,context) can assign server-owned values and derived rows through tracked write methods; a rejection rolls back the entire transaction. Its final row revisions enter both receipts and replay.

The socket handler exposes exchange(request,context) for server-originated writes and peer notifications. It returns a complete snapshot or contiguous transaction replay with operation receipts.

All writes for a WebSocket scope must enter through this handler so peers receive notifications; direct openStore mutations bypass its replay log. Retrying an operation binds its identity to the exact payload and returns the original decision.

src/local-object.ts
import { DurableObject } from "cloudflare:workers";
import { openSyncSocket } from "renku/data/sync";
import { taskReplica } from "./local-model.ts";
/** Public demo capability: possession grants access to one disposable board. */
export class LocalTasks extends DurableObject {
  private sync = openSyncSocket(this.ctx, (capability: string) => ({
    definition: taskReplica,
    scope: "demo:" + capability,
    maxRecords: 100,
    authorize: (context: string) => /^[a-f0-9]{64}$/.test(context) && context === capability,
    validate: (tx) => {
      for (const { data } of tx.tasks.list({ limit: 200 }).items)
        if (!data.title.trim() || data.title.length > 120 || data.minutes < 1 || data.minutes > 480)
          return "TASK_INVALID";
    },
  }));
  fetch(request: Request) {
    const capability = new URL(request.url).searchParams.get("capability") ?? "";
    return this.sync.fetch(request, capability);
  }
  webSocketMessage(socket: WebSocket, message: string | ArrayBuffer) {
    return this.sync.webSocketMessage(socket, message);
  }
  webSocketClose(socket: WebSocket, code: number) { this.sync.webSocketClose(socket, code); }
  webSocketError(socket: WebSocket) { this.sync.webSocketError(socket); }
}

Mount an app handler with sync: "./src/local-handler.ts" in renku.config.ts. The same-origin /_renku/sync endpoint forwards upgrades to the authorized object. The demo uses a random browser-held capability for an isolated disposable board.

Production apps must authenticate the handshake and each message, select the object from trusted membership, and bind the scope to the principal and permission generation. Identifiers are not authorization. Do not reuse another principal’s namespace.

Offline devices retain previously downloaded data; revocation cannot erase a disconnected device.

src/local-handler.ts
import { exports as workerExports } from "cloudflare:workers";
import type { LocalTasks } from "./local-object.ts";
export default {
  fetch(request: Request) {
    const capability = new URL(request.url).searchParams.get("capability");
    if (!capability || !/^[a-f0-9]{64}$/.test(capability))
      return new Response("Invalid demo capability", { status: 403 });
    const { LocalTasks: namespace } = workerExports as {
      LocalTasks: DurableObjectNamespace<LocalTasks>;
    };
    return namespace.getByName(capability).fetch(request);
  },
};

Updates and deletes capture row revisions. Local predecessor references let a device’s own edits follow one another. A stale authoritative row rejects the edit; its intent remains visible and descendants block. Retry is an explicit new write against current state.

Authority incarnation changes preserve and block old intent. Sync serializes across tabs and uploads a known outbox cut; a snapshot carries receipt coverage for that cut. Edits admitted during sync remain queued.

Default authority limits are 1,000 records and 1 MiB canonical row JSON, configurable up to 10,000 / 4 MiB. The demo limits each board to 100 tasks. WebSocket requests are bounded to 1 MiB; replies carry the configured bounded snapshot or replay. Replica definitions are bounded to 64 KiB.

  • Exchanges upload at most 50 commands, with at most 1,000 queued pending identities. Replay retains 1,000 transactions by default; older cursors get complete snapshots. Receipts and completed local admission identities persist for idempotency.
  • Server retireClient is an application-controlled irreversible operation that removes a client’s receipts and keeps a retirement tombstone. Completed local bodies compact to SHA-256 identity records; acknowledgements release server dependency heads once no pending edit depends on them.
  • Receipt payloads are SHA-256 fingerprints. Tombstones never expire automatically. There is no automatic schema migration or database reset. storageUsage() measures JSON payload bytes separately for projection, replay and receipts; physical SQLite allocation and indexes are additional.

Supply project(readStore, window, context) returning {rows,next} to openSync/openSyncSocket. Limits then bound each public projection rather than authoritative history. setWindow({key,after}) selects a bounded snapshot; snapshot().selection carries its request and next cursor.

The app validates keys and uses signed store cursors. Projected mode returns atomic snapshots with receipt coverage and the authorization generation supplied in options.generation; it does not maintain a full duplicate projection or row replay log.

Commands captured under another generation reject visibly. Pending update targets are pinned through window eviction. query({table,index,equals,limit}) reads indexed SQLite rows; observe(query,listener) publishes only changed query results.

Limits are 1–200 and equality fields must form an index prefix. Existing full-state subscribers remain available.

Offline identity is explicit application policy.

  • After getSession validates online, trustLocalSession(maxAgeMs) enables getLocalSession for at most seven days, capped by session expiry. disableLocalSession removes that permission; lockLocalSession locks access immediately and broadcasts an auth change, even offline.
  • This does not revoke a server cookie without a successful signOut. Cached identities carry no server authority.
  • Bind openReplica.principal to the authenticated account and remount on account change. readDeviceValue/writeDeviceValue hold principal-scoped app directory data. reviewDeviceReplicas reads every registered outbox with sync paused; downloadDeviceReview exports pending/rejected work. forgetDeviceReplicas(principal,{discard:true}) requires an intentional discard decision, closes tabs, waits for SQLite ownership, deletes files and advances a durable device epoch.
  • Review/export is a point-in-time view, so explain that concurrent later edits will also be discarded. close() only releases the replica. Browser eviction and device loss are not recoverable without a backup.

Network failures retry with bounded exponential jitter. Authorization, schema, retired-client and capacity failures publish distinct sync states and stop retries, including pushes and online events, until resume(). No terminal failure deletes queued work.

The socket adapter recognizes explicit FORBIDDEN/AUTH_REQUIRED errors; unknown resolver failures remain transient. A same-origin authenticated POST probe precedes a new WebSocket so HTTP 401/403 and 409 can be distinguished from network failure.

App sync handlers must forward both probe POSTs and upgrades. Revalidate authentication or reload the app as appropriate before offering resume.

A Web Lock elects one dedicated worker per local namespace; BroadcastChannel forwards other tabs’ operations. If its tab freezes, other tabs wait instead of risking two writers. A terminated owner releases the lock and another candidate reopens SQLite.

SAH-pool initialization explicitly preserves files on failure. Process-crash recovery is tested; OS power loss, browser eviction and device loss are not backups.

You can connect a bounded browser replica to an authorized Durable Object and plan conflict and device recovery.

Continue to authentication

← All guides · Explore the docs source (access required) →