How Pulsify Cloud works

Pulsify Cloud is the managed, paid backend for your pulses: a CRDT database you never operate. The client code is the one you already write with pulsify-core; only the connector changes.

  • Automatic scaling. Each root document is its own isolated actor with its own storage. A million users means a million small actors, not one big database.
  • Global distribution. On Cloudflare, a root lives near its first user and is reachable from every location.
  • Pay as you go. Idle sockets hibernate and idle instances scale to zero. You pay for activity and storage, not for capacity.
  • Built-in authentication. Users connect with a token from your backend; each root keeps its own access table, and changes apply to open sockets immediately.

Roots and documents

A document belongs to the root named before its first /: toto/tata belongs to toto. Sub-pulses created with { parent } get such IDs automatically, so a whole tree lives in one root.

one root per namespace
alice                     root: one namespace, one actor
├── alice/notes/n1        sub-document
├── alice/notes/n2        sub-document
└── alice/work            sub-document
    └── alice/work/plan   sub-document of a sub-document
bob                       another root, isolated from alice
acme                      a root does not have to be a user

A root is usually a user, but nothing enforces it. Use a team, an organization or a project if that is how your data is shared. Pick roots so that the documents edited together live together: a root is the unit of placement, storage and isolation.

One socket per user and root

The connector opens one WebSocket per root and multiplexes every document of that root over it. Pulsify frames already name their document, so the cloud routes them without decoding the operations. Ten open notes cost one socket.

ClientGatewayRoot aliceupgrade /v1/roots/alice + tokenverify token → subjectsocket + subjectconnect alicerole owner, sync aliceconnect alice/notes/n1role owner, sync n1updates of alice and n1 only
One socket, many documents. The root checks each document against its access table.
cloud.ts
import { createCloudConnector } from "pulsify-cloud";
import { loadPulse } from "pulsify-core";

const cloud = createCloudConnector({
  url: "https://pulsify.example.com",                // Cloudflare or Unikraft: same URL scheme
  token: () => fetch("/api/pulsify-token").then((response) => response.text()),
});

const notebook = await loadPulse(Notebook, "alice", { connectors: [cloud], include: true });
cloud.connections; // [{ root: "alice", state: "open", documents: ["alice", "alice/notes/n1", …], … }]

Subscriptions

Loading a pulse subscribes to it; the server never pushes documents you did not load. Subscribe to exactly what a screen needs:

subscriptions.ts
// The root document only
await loadPulse(Notebook, "alice", { connectors: [cloud] });

// One sub-document, without its parent
await loadPulse(Note, "alice/notes/n1", { connectors: [cloud] });

// Everything reachable from the root
await loadPulse(Notebook, "alice", { connectors: [cloud], include: true });

// A sub-document and all its children
await loadPulse(Folder, "alice/work", { connectors: [cloud], include: true });

// Selected fields only
await loadPulse(Notebook, "alice", { connectors: [cloud], include: { notes: true } });

// Unsubscribe: the socket stays open for the other documents of the root
destroyPulse(note); // or disconnectPulse(note)

Each subscription has its own version vector, so opening a document downloads only its missing rows. When the last document of a root is closed, the socket closes after lingerMs (5 seconds by default).

Lost connections

Connected pulses reconnect on their own with backoff, and every reconnection is a complete synchronization. All documents of a root come back on one new socket.

A socket can also die without closing: a laptop that slept, a network switch, a proxy dropping idle connections. The connector checks sockets when they are needed, once per socket rather than per document:

  • Before using a silent socket. An edit sent on a socket that received nothing for 5 seconds goes out with a ping. Any frame counts as an answer, so a busy socket never pings.
  • When the user comes back. Focusing the window or showing the page checks silent sockets; the network coming back checks all of them.
  • Without an answer within 5 seconds, the socket is closed and its documents reconnect, the edit included.
  • Passive readers. Nothing needs a socket that only receives, so while someone is there (input in the last 5 minutes, page not hidden) a silent socket is pinged every 2 seconds. Any frame counts as an answer, so busy sockets never ping. Set intervalMs: false to check on demand only.
  • A page left open (hidden, or untouched for 5 minutes) sends nothing at all, so the server can sleep. Its sockets stay open and still receive updates; if one died meanwhile, the first input, focus or edit finds out within 5 seconds and resynchronizes.
cloud.ts
import { createCloudConnector, createPresence } from "pulsify-cloud";

const cloud = createCloudConnector({
  url,
  token,
  heartbeat: {
    staleMs: 5_000,    // ping a socket silent this long before using it
    timeoutMs: 5_000,  // no frame this long after a ping: the socket is dead
    intervalMs: 2_000, // background check while someone is there; false = on demand only
  },                   // defaults; heartbeat: false relies on transport errors
  // Optional: by default every connector of the page shares `presence`.
  presence: createPresence({
    activeMs: 300_000,   // "someone is there": input within this long, page not hidden
    unfocusedMs: 30_000, // ...or within this long when the window lost focus
  }),
});

On Cloudflare the runtime answers pings itself: a hibernated root stays asleep and no duration is billed. Each ping still counts as an incoming WebSocket message, billed as 1/20 of a request. On Unikraft Cloud, a ping wakes a scaled-to-zero instance for a moment: only pages in use ping in the background, so instances sleep when users walk away.

A screen nobody touches, such as a dashboard on an office TV or a kiosk, stops background pings after 5 minutes like any idle page. It still receives updates, but a socket that died silently would only be noticed at the next touch. Give such displays activeMs: Infinity.

Desktop and mobile apps

Presence works in every webview because it relies on what they all report: input events (pointer, keyboard, wheel, touch) and timers. When timers run late, the process was suspended (laptop sleep, mobile app in the background), and every socket is checked on resume. Visibility, focus, pageshow and resume only make detection faster where the webview reports them:

RuntimePage hidden when minimized or in the background
Browsers, Electron (Chromium)Yes
Tauri, Electrobun on macOS (WKWebView)On minimize, not when covered by another window
Tauri, Electrobun on Windows (WebView2)Never (tauri#10592). Minimizing removes the focus, so pings stop 30 seconds after the last input; at once with native events.
Tauri on iOS and AndroidThe app is suspended in the background; sockets are checked on resume

A window that loses focus (another app in front, or minimized) counts as away 30 seconds after the last input instead of 5 minutes. Every webview reports focus, including WebView2. To react the moment a window is minimized or hidden, forward the native state to the page presence, which every connector shares:

presence.ts
// Tauri, desktop and mobile: minimized or hidden means away, shown or focused means back.
import { presence } from "pulsify-cloud";
import { watchTauriPresence } from "pulsify-tauri";

const stop = await watchTauriPresence(presence);

Electron needs nothing: Chromium reports minimized and hidden windows to the page.

Debugging and status displays

A client talks to several servers at once: each root it uses has its own socket, served by that root's Durable Object or Unikraft machine. So events are reported where they happen:

  • Presence belongs to the window, not to any server: whether someone is there, and why.
  • Each connection reports its own server (the first frame of every socket names the backend, the Durable Object ID or machine instance, and the Cloudflare edge or Unikraft metro), its lifecycle, every ping with why it was sent, round trips, a dead socket and process suspensions. A reconnection is a new connection; a new server ID means a new instance.
  • Each pulse reports its synchronization status.

Use them for a debug panel, logs, or to show the state of a window.

debug.ts
import { presence } from "pulsify-cloud";
import { subscribePulseStatus } from "pulsify-core";

// The window: called now, then on every change. It involves no server.
presence.on("change", (p) => {
  // { present, reason: "blur" | "idle" | "focus" | "native" | …, visible, focused, away, lastActivity }
  presenceBadge.textContent = p.present ? "here" : `away (${p.reason})`;
});

// The servers: one connection per root, to its Durable Object or Unikraft machine.
// Called with the current connections, then with each new one (reconnections included).
cloud.onConnection((connection) => {
  const { root } = connection;
  connection.on("server", ({ backend, id, location }) => log(root, "served by", backend, id, location));
  connection.on("state", (state, error) => log(root, state, error?.message)); // open | closed
  connection.on("ping", (reason) => log(root, "ping", reason)); // send | interval | network | return | resume
  connection.on("pong", (latencyMs) => log(root, "pong", latencyMs));
  connection.on("timeout", (waitedMs) => log(root, "dead socket", waitedMs));
  connection.on("suspended", (gapMs) => log(root, "process was suspended for", gapMs));
  connection.on("role", (docId, role) => log(docId, "role", role));
});

cloud.connections; // the current ones, without listening: root, state, server, documents, latencyMs, error
presence.state;    // the same for the window

// Documents: idle | syncing | ready | synced | offline | error.
subscribePulseStatus(notebook, (status) => (statusBadge.textContent = status));

Storage and access

Each root stores three things:

  • Patch rows of all its documents, in the same schema as the self-hosted stores. They are never decoded on the server: definitions, migrations and materializers stay in your client code.
  • Grants: who can read, edit or own which prefix. See Authentication.
  • Subscriptions of the sockets currently open, so a hibernated root wakes up knowing whom to notify.

Packages and backends

ImportContainsLicense
pulsify-cloudThe client: connector, presence, management client, tokens, roles and the session protocol.Apache-2.0
pulsify-cloud-serverThe gateway and CloudRoot, the runtime-independent state machine of a root.FSL-1.1-ALv2: any use except a competing service; Apache-2.0 two years after each release
pulsify-cloud-server/cloudflareA Worker and one Durable Object per root.
pulsify-cloud-server/unikraft-cloudA Rust server and PostgreSQL in one Unikraft Cloud image, plus its schema.
CloudflareUnikraft Cloud
GatewayWorkerRust server
One rootOne Durable ObjectOne actor in the server process
StorageSQLite, one database per rootPostgreSQL beside the server, one database per instance
Idle costWebSocket hibernationStateful scale-to-zero
PlacementNear the first user of each rootThe metro of the instance

Both backends pass the same conformance suite: same URLs, same errors, same live role changes. Switching is a URL change.

Wire protocol

cloud session protocol 1
GET  /v1/health                         { "ok": true }
GET  /v1/roots/{root}/connect           WebSocket, one per user and root
GET  /v1/roots/{root}/grants            list grants
PUT  /v1/roots/{root}/grants            create or replace a grant
DELETE /v1/roots/{root}/grants?subject=…&prefix=…
GET  /v1/roots/{root}/usage             documents, rows, bytes, sockets (API key)

Subprotocols offered by the client:
  pulsify-cloud.1.e2, pulsify-cloud.1.e1, pulsify-cloud.token.<jwt>
Binary frames: unchanged Pulsify frames, each names its document
Text frames:   {"type":"welcome","server":{backend,id,location?}} server → client, first frame
               {"type":"role","docId":…,"role":"editor"|…|null}   server → client
               {"type":"unsubscribe","docId":…}                    client → server
               {"type":"ping"} → {"type":"pong"}                   socket heartbeat

The token travels as a WebSocket subprotocol because browsers cannot set headers on a socket; the server answers with the envelope only. Envelope 2 groups scalar runs; the server may still answer in envelope 1.