14 min read essay protocol decentralization

The Tip of the Hook

You write useQuery(TaskSchema) and get a live, local, cryptographically-authorised, syncing database — with no API endpoint, no auth middleware, and no cache to invalidate. A developer's tour of xNet's React hooks on the surface, then a dive beneath the waterline to the SQLite database running in a worker, the priority scheduler, and the signed change log that make “just trust the client” safe. The tip is small on purpose; the iceberg is yours to open.

Sit down to build a task list the normal way and, before you write a line of UI, you owe the machine a small bureaucracy. An API endpoint to list the tasks. A second to create one. Authentication to know who’s asking. Authorisation middleware to decide what they’re allowed to see. An ORM and a database behind that. A client-side cache so the screen doesn’t flicker, and a pile of code to keep that cache honest when the data changes. Only then do you get to render a list.

In xNet you write this instead — and you’re done:

function Tasks() {
  const { data: tasks, loading } = useQuery(TaskSchema, {
    where: { status: 'todo' },
    orderBy: { createdAt: 'desc' }
  })
  const { create } = useMutate()

  // tasks is live, local and already authorised — no endpoint, no fetch
  if (loading) return <Spinner />
  return <TaskList items={tasks}
    onCreate={(title) => create(TaskSchema, { title, status: 'todo' })} />
}
A complete, live, authorised, offline-capable task list. There is no endpoint behind it, because there is no “behind it.”

That’s the whole feature. tasks is live: edit one on your phone and this list updates on your laptop. It’s local: it reads from a database a few millimetres away, so it’s instant and works on a plane. It’s authorised: a user who isn’t allowed to see a task never receives it. And you wired up none of that.

This essay is a tour of the hooks on the surface and a dive into everything that one useQuery quietly stands on — a signed change log, a real SQL database running in a background thread, a scheduler, a sync engine. It’s the tip of an iceberg, and the point of the tour is that you’re allowed to look under the waterline. There’s code along the way; every grey panel is skippable and the story still holds — but being able to show you the machine is half the argument.

One: there is no API

Start with the thing that’s missing. In the usual architecture there is a tier in the middle — the endpoints, the request handlers, the serialisers — whose entire job is to stand between your component and the data and mediate every conversation. You design it, version it, secure it, deploy it, and pay for it to be awake at 3am.

xNet deletes that tier. The hook is the interface to your data. Your “API design” becomes a much smaller, friendlier thing: which schemas exist, and which hooks read and write them. Both live in your client, in TypeScript, fully typed.

flowchart LR
  subgraph STACK["The usual way — a server tier in the middle"]
    direction LR
    A1["Component"] --> A2["fetch /<br/>GraphQL"]
    A2 -->|"network"| A3["API<br/>endpoint"]
    A3 --> A4["Auth<br/>middleware"]
    A4 --> A5["ORM"]
    A5 --> A6["Database<br/>(theirs)"]
  end
  subgraph XN["xNet — the hook is the API"]
    direction LR
    B1["Component"] --> B2["useQuery(TaskSchema)"]
    B2 --> B3["SQLite on<br/>your device"]
    B3 -.->|"later, optional"| B4["Hub (relay)"]
  end
The same app, two shapes. The usual way routes every read through a server tier you own and operate. xNet points the hook straight at a database on the device, and treats the network as an optional courier.

It sounds reckless the first time you hear it — the client talks straight to the database? — and it would be, on the old web, where “trust the client” is a punchline. The rest of this piece is really one long answer to why it’s safe here. But first, the tour.

Two: a tour of the hooks

There are only a handful, and they read like the data tools you already know. useQuery reads. Hand it a schema for the whole set, a schema and an id for one, or a schema and a filter for a slice — where, orderBy, limit, and for the ambitious, full-text search and spatial windows for canvases. What comes back is already live; you don’t poll and you don’t re-fetch.

useMutate writes — create, update, remove, or several at once in a single atomic mutate. The change lands in your local database first, so the UI updates on the same tick; the network catches up afterwards, if it’s there at all. useNode is for editing one thing deeply: it hands you the node and a collaborative document with live cursors and presence, for when two people are in the same paragraph. And useInfiniteQuery grows a window rather than freezing pages — load more and the earlier rows stay live instead of going stale.

Notice what the surface snippet didn’t contain: no fetch, no query keys to invalidate, no loading spinner plumbing beyond a single boolean, no websocket. The ergonomics are deliberately the ones a React developer already has in their fingers — the backend behind them is just no longer someone else’s server.

Three: the schema is the API — authorisation included

If the hooks are how you read and write, the schema is where you say what a thing is and who may touch it — and both halves live in the same small object. Here is a task whose editors are named in a property, whose owner is whoever created it, and whose rules are stated right next to its fields.

Open the panel — a schema with its authorisation
packages/data/src/auth/builders.ts (shape)
export const TaskSchema = defineSchema({
  name: 'Task', namespace: 'xnet://xnet.fyi/',
  properties: { title: text({ required: true }), status: select({ options: STATUSES }) },
  authorization: {
    roles:   { owner: role.creator(), editor: role.property('editors') },
    actions: {
      read:   allow('editor', 'owner'),
      write:  allow('editor', 'owner'),
      delete: allow('owner')
    }
  }
})
Roles are resolved from the data itself — the creator, the DIDs listed in a property, a role inherited from a related node or a Space. Actions name which roles they admit. This block is the access-control policy; there is no second copy on a server.

This is worth dwelling on, because it’s where most systems quietly split in two. Normally your client has an opinion about permissions — it greys out a button — and the server holds the truth, re-checking everything because the client can’t be trusted. Two implementations of the same rules, forever drifting apart. In xNet there is one declaration. It’s what greys out the button and it’s what’s enforced. When you can’t work out why something was allowed or denied, useAuthTrace will hand you the roles, the grants, and the reasons it decided the way it did.

Which raises the obvious objection, and it deserves a straight answer.

Four: why you can trust a rule you wrote on the client

The old web’s iron law is “never trust the client,” and it’s correct there, because a rule that only runs on a device the attacker controls is a suggestion. xNet doesn’t trust the client either. What it trusts is cryptography, and that changes where the rules can safely live.

Every write becomes a small, signed record — a change — sealed with a key only you hold (Ed25519) and fingerprinted so the whole history forms a tamper-evident chain (BLAKE3). Your identity isn’t an account some company can switch off; it’s a key pair, and your public name is literally your public key. (The companion essay, The Loom You Can Read, follows a single change through that machinery in detail.) The consequence that matters here: any device can check, on its own, that a change really came from who it claims and wasn’t altered — and can run the schema’s rules against it — before letting it touch the database.

Open the panel — the gate every change passes through
packages/sync/src/integrity.ts · packages/data/src/auth/evaluator.ts
// runs before any incoming change touches your database
if (!verifyChangeHash(change)) reject('INVALID_HASH')    // recompute BLAKE3, compare
if (!verifySignature(change)) reject('BAD_SIGNATURE')  // Ed25519, key from the DID

const decision = await evaluator.check(authorDID, change, 'write')
if (!decision.allowed) reject(decision.reasons)  // the rules you declared, enforced
Verify the fingerprint, verify the signature, then evaluate the very rules you declared in the schema. Only a change that survives all three is applied — so the policy you wrote on the client is enforced by maths, not by trusting the wire.
sequenceDiagram
  participant U as Your device
  participant Hub as Hub (relay, low trust)
  participant P as Collaborator
  U->>U: useMutate update — becomes a signed, hash-chained change
  U->>U: write to local SQLite — your screen updates instantly
  U->>Hub: send the signed change
  Hub->>Hub: verify signature, recompute hash (INVALID_HASH guard)
  Hub-->>P: relay it — cannot forge it, cannot rewrite the chain
  P->>P: verify, authorise against the schema, then merge (last-write-wins)
  P->>P: the live query deltas the new value into view
The relay in the middle is barely trusted. It can’t forge your signature and it can’t quietly rewrite your history without breaking the chain. Your collaborator’s device verifies and authorises on its own before merging.

So “specify your whole API in the client” isn’t a shortcut that trades away safety. The authority moved from a privileged place in the network to a property of the data. A rule attached to a signed, verifiable object is enforceable anywhere that object travels.

Five: how a view materialises

Back to that live list. When you call useQuery, it doesn’t hand React an array; it hands React a subscription — a way to read the current answer synchronously, and a way to be told when the answer changes. That’s the exact shape React 18’s useSyncExternalStore wants, which is why the result stays tear-free under concurrent rendering without any effort from you.

Open the panel — the contract behind a live query
packages/data-bridge/src/types.ts
interface QuerySubscription<P> {
  getSnapshot(): NodeState[] | null      // synchronous read from the cache
  subscribe(cb: () => void): () => void  // React calls this to stay live
}

// useQuery feeds exactly this to React.useSyncExternalStore — no more, no less
Two methods: read now, and tell me when it changes. Everything else — the cache, the worker, the database — sits behind this tiny interface.

Behind that interface, a cache keeps the materialised result. When data changes, the bridge doesn’t re-run your query from scratch; it keeps a small buffer of spare rows around the edge of your window and applies the change in place — slot a new row into its sorted position, update one, drop one — only falling back to a full reload when a burst of changes is too large to patch. Unchanged rows keep their identity, so the components rendering them simply don’t re-render.

sequenceDiagram
  participant C as Component
  participant H as useQuery
  participant Br as Bridge + cache
  participant W as Data worker
  participant S as SQLite worker
  C->>H: useQuery(TaskSchema, where status = todo)
  H->>Br: subscribe(descriptor)
  Br->>W: load (first time only)
  W->>S: SELECT … (off the main thread)
  S-->>W: rows
  W-->>Br: snapshot
  Br-->>H: getSnapshot()
  H-->>C: render
  Note over Br,W: an edit lands (local, or just-synced from a peer)
  W-->>Br: bounded delta — small change in place, big burst reloads
  Br-->>H: notify
  H-->>C: re-render only the rows that changed
First call reads from the database once. After that, edits — yours, or ones that just synced in from someone else — arrive as small deltas, and only the rows that actually changed re-render.

A precise word on “live,” because honesty is the house style. Your queries are live with respect to your local database: any change written there flows into the view, and that includes changes that arrive from a peer and get merged in locally. A pushier mode — the hub streaming results to you as they happen — is designed and named in the code but not switched on yet. When it is, it’ll slot in behind this same subscription, and your useQuery won’t change a character.

Six: the layer beneath the waterline

Now the dive. “Reads from a local database” is doing a lot of quiet work in the sentences above. That database is real SQLite — the same engine in your phone — compiled to WebAssembly and run inside a Web Worker, off the thread that paints your UI. It persists to a private corner of the browser called the Origin Private File System, so your data is still there tomorrow.

Open the panel — a real database, tuned, on a background thread
packages/sqlite/src/adapters/web.ts
// inside a Web Worker — never on the thread that paints your UI
this.poolUtil = await sqlite3.installOpfsSAHPoolVfs({ name: 'opfs-sahpool' })
this.db = new this.poolUtil.OpfsSAHPoolDb(dbPath, 'c')
this.execSync('PRAGMA cache_size = -262144')    // 256 MB page cache
this.execSync('PRAGMA mmap_size  = 268435456')  // fault pages via the OS
this.execSync('PRAGMA journal_mode = TRUNCATE')  // fastest durable mode on OPFS
A genuine SQLite database, persisted to disk and tuned for large stores — a 256 MB page cache, memory-mapped reads, the fastest durable journal mode on OPFS. You didn’t set any of it.

There are actually two workers, and they talk to each other directly rather than bouncing every message through your UI thread: one owns the store and the live subscriptions, the other owns SQLite. Signing and verifying happen out here too, so the cryptography never janks a scroll. A single database connection can only do one thing at a time, so a small scheduler decides the order — and it always lets an interactive read jump ahead of a background import.

Open the panel — why a tap never waits behind an import
packages/sqlite/src/adapters/worker-scheduler.ts
const LANE_ORDER = ['interactive', 'bulk', 'write'] as const

// drained in that order, so a tap never waits behind a bulk import;
// and two identical reads in flight coalesce into a single execution.
Three lanes, drained in priority order, plus de-duplication of identical in-flight reads. The result: typing stays responsive even while ten thousand rows are importing in the background.
flowchart TB
  subgraph MAIN["Main thread — your React app"]
    UI["Components + hooks"] --> BR["Worker bridge (Comlink)"]
  end
  subgraph DW["Data worker"]
    NS["Store + live query subscriptions"]
    SG["sign / verify — off the UI thread"]
  end
  subgraph SW["SQLite worker"]
    SCH["Priority scheduler<br/>interactive → bulk → write"]
    DB["SQLite (WebAssembly)"]
  end
  BR <-->|"RPC"| NS
  NS <-->|"direct MessagePort"| SCH
  SCH --> DB
  DB --> OPFS[("OPFS on your disk<br/>with graceful fallbacks")]
The shape under the surface. Your hooks talk to a bridge; the bridge talks to a data worker; the data worker talks straight to the SQLite worker over a transferred channel. The heavy lifting — SQL, OPFS I/O, signing — happens where it can’t freeze the interface.

And here’s the part that earns the phrase it just works: the database layer probes what the device can actually do and adapts. On a modern browser it takes fast, exclusive file handles. On an older one, or a locked-down webview, it falls back to a slower-but-durable mode, and only as a last resort to an in-memory database — loudly, so it’s never a silent surprise. The same useQuery runs on a flagship laptop and a three-year-old phone; the layer underneath quietly picks the best engine each one can offer.

Seven: local first, mirrored to remote

Because the real copy is on your device, a query is just a query against your own SQLite — instant, offline, no permission to ask. For most workspaces that’s the whole story; everything you own fits happily on the device. When a dataset is large, or a request is one only the fleet can answer — a full-text search across more than you hold, say — a router decides to involve the hub, and can serve the local answer immediately while a fuller one refreshes in from the network behind it.

New data doesn’t arrive as a refetch. A peer’s signed change comes in, gets verified and authorised, and merges into your local database by a simple, deterministic rule — last edit wins, with a logical clock and the author’s key breaking ties, so every device reaches the same answer with no server casting a deciding vote. The moment it lands locally, the live query from section five deltas it into your view. “Mirrored against a remote database and merging in new data as needed” isn’t a feature bolted on top; it’s what falls out of a signed change log meeting a live local query.

It just works

Come back up to the surface and look again at the dozen lines we started with. Here is an incomplete list of what you did not write to make them true: an API endpoint, request handlers, an authentication layer, authorisation middleware, an ORM, a database migration runner, a client-side cache, the logic to invalidate it, a websocket, an offline queue, a background sync worker, a conflict resolver, and a connection pool. They’re all present. You just didn’t have to assemble them, and — this is the part that matters — you’re allowed to open every one.

It’s worth being clear about the company xNet keeps, because it isn’t alone in wanting this. Several good projects give you reactive queries over a local store; most of them keep a server as the root of trust.

Approach Database of record Identity is… Rules enforced by… Offline writes
TanStack Query / SWR none (a remote cache) the app’s server the server No
Convex / InstantDB a vendor cloud a vendor account the vendor backend Partial
Replicache / Zero your server your server’s auth the server Yes (optimistic)
ElectricSQL / PowerSync a server Postgres your server’s auth the server / rules Yes
xNet local SQLite (yours) a did:key you mint signed, hash-chained changes Yes, first-class

The family resemblance is real, and the borrowed ergonomics are on purpose — if you’ve used useQuery from one of these, ours will feel like home. What’s distinctly xNet’s is the corner you’ve been standing in this whole tour: the master copy is on the edge device, the authority is a key in your hands, and the network is a convenience layered on top — not the place your work is kept.

None of the machinery beneath the waterline is exotic. SQLite, a worker, a hash, a signature, a tiny merge rule, a subscription. It’s plain engineering, arranged so that the simple thing on top — a hook you can read in one sitting — is also the correct thing, and the openable thing. So open it. Use the app, then read the source the excerpts above point at. Build something on the hooks and watch your “whole API” turn out to be a couple of schemas and a component. The tip is small on purpose. The iceberg is yours to inspect, all the way down.


Sources

Code excerpts are trimmed and lightly simplified for reading; the file paths point at the real, unabridged source. “Live” queries are live with respect to the local database (including changes synced in from peers); the hub-push streaming mode is designed but not yet enabled, and is noted as such above. All artwork here is original.