Self-host: databases

A Pulsify server is a relay plus a store. Run both yourself in front of PostgreSQL or SQLite, or let Pulsify Cloud do it.

How storage works

Every committed change is an immutable row: (docId, actor, clock, span, dependencies, bytes). The store never decodes operations or instantiates a schema. Two queries do all the work:

  • vector(docId) returns the contiguous causal frontier per actor.
  • missing(docId, remoteVector) returns only the rows a client lacks, by index, without loading the document.

Rows arriving out of order stay dangling until their predecessors land, so a partially uploaded history can never be served as complete. Duplicates are idempotent; forks of one actor's history roll back the whole batch.

Self-host: relay

The relay fans frames out to connected peers of the same document and answers initial downloads. The reference relay is in-memory; add the store at the boundary and append before acknowledging.

server.ts
import { PulseRelay, type RelayPeer } from "pulsify-core";

const relay = new PulseRelay();

Bun.serve<{ peer?: RelayPeer }>({
  port: 3000,
  fetch(request, server) {
    if (new URL(request.url).pathname !== "/sync") return new Response("Not found", { status: 404 });
    const offered = request.headers.get("sec-websocket-protocol") ?? "";
    const version = offered.includes("pulsify-v3") ? 3 : 1;
    const headers = version > 1 ? { "Sec-WebSocket-Protocol": "pulsify-v3" } : {};
    return server.upgrade(request, { data: {}, headers }) ? undefined : new Response(null, { status: 400 });
  },
  websocket: {
    open(socket) {
      socket.data.peer = relay.open((bytes) => socket.sendBinary(bytes), { protocolVersion: 3 });
    },
    message(socket, message) {
      if (typeof message === "string") return socket.close(1003);
      socket.data.peer!.receive(new Uint8Array(message));
    },
    close(socket) { socket.data.peer?.close(); },
  },
});

Self-host: PostgreSQL

PostgreSQL is the primary target. An append is one call to pulsify_append, which takes a per-document advisory lock, checks only neighboring segments, inserts the batch and promotes rows whose dependencies are now met. Payloads are bytea[], never base64.

store.ts
import { SQL } from "bun";
import { PostgresPatchStore } from "pulsify-core/storage";

const sql = new SQL(process.env.DATABASE_URL!);
const store = new PostgresPatchStore((query, params) => sql.unsafe(query, params as never[]));
await store.initialize(); // once, installs tables and pulsify_append()

// Persist an incoming update frame: one round trip, no document decoding.
await store.appendEncoded(frameBytes);

// Serve a client: rows it has not seen, straight from the index.
const rows = await store.missing(docId, clientVersionVector);
const vector = await store.vector(docId);

Any driver works as long as it binds the third parameter as a binary array. Bun's SQL does; a pg adapter must preserve Uint8Array values.

Self-host: SQLite

Same API, same indexes, immediate transactions. Ideal for single-node servers, desktop apps and tests.

store.ts
import { Database } from "bun:sqlite";
import { SqlitePatchStore } from "pulsify-core/storage";

const store = new SqlitePatchStore(new Database("pulsify.db"));
await store.initialize();
await store.appendEncoded(frameBytes);

Measurements and the conformance suite for both stores are in the storage guide.

Or use Pulsify Cloud

The managed backend runs the same stores for you: one isolated root per namespace, one socket per user and root, built-in access control. From the client it is a connector.

app.ts
import { createCloudConnector } from "pulsify-cloud";

const cloud = createCloudConnector({ url: PULSIFY_URL, token: fetchToken });
const note = await loadPulse(Note, "alice/notes/n1", { connectors: [cloud] });

How Pulsify Cloud works →