Skip to content
rifleDB

Every client in sync. One server in charge.

rifleDB is a realtime database for apps people use together. Read and write over REST, subscribe to the same paths over WebSocket, and let folders and server actions decide who may change what.

One Node.js server with Redis behind it, an SDK for React, Next.js and plain TypeScript, and an admin app to watch it all run.

  • A visitor drops a platePOST /v1/demo/world/@drop{ "q": 1, "r": 3, "type": "l" }
  • The server merged three platesdemo/world/col/-3_2["t0", "f0", "c2"]
Rendered at build time from the demo’s own rules: 87 drops, one season.

Three things to build before your first feature.

A multiplayer game, a shared board, a live dashboard: anything people use together needs the same plumbing first. rifleDB is that plumbing, as one server you run yourself.
  • Keeping every client in sync

    Usually: A socket server of your own, reconnect and resubscribe logic, and optimistic updates that roll back when the server says no.

    With rifleDB: Subscribe to a path. Writes show up at once, reconcile with the server’s answer, and replay after a reconnect.

  • Deciding who may read and write

    Usually: Sign-in, sessions, and the same permission checks written twice: once for the HTTP API, once for the socket.

    With rifleDB: Every path lives in a public, private or secret folder. Clients sign with device keys, and one set of rules covers REST and WebSocket.

  • Gluing the realtime parts together

    Usually: Fan-out to the right clients, throttling for cursors and sliders, an HTTP API for scripts, and checks that stop a client from writing a score it didn’t earn.

    With rifleDB: Fast folders coalesce what moves, every path is also a REST URL, and server actions decide what clients may only ask for.

How it works

Paths hold the data, folders guard them, fast folders carry what moves, and actions let the server decide. Each works the same over REST and WebSocket.

One path, two ways in.

Every value lives at a path like polls/launch/votes. Browsers subscribe over one WebSocket per tab; scripts, cron jobs and curl use plain HTTP with ETags and problem+json errors.

Both reach the same data, under the same rules, in the same order. No polling anywhere: changes are pushed as they commit.

polls/launch/votes

Over HTTP, at /v1/polls/launch/votes

GET
Read it. ?prefix=1 lists a subtree, ?stream=1 streams changes.
PUT
Set it. With If-Match it only lands if nobody changed it first.
PATCH
Apply an op such as inc, push or merge, or a JSON merge patch.
POST
Push onto a list. POST /v1/<folder>/@<action> runs an action.
DELETE
Delete it.

Over WebSocket

sub { path: 'polls/launch/votes' }
The current value, then every change as it commits.
op { id, path, op: 'inc' }
Write. The ack carries the new revision; a retried id is not applied twice (within five minutes).
act { id, folder, name: 'vote', args }
Run a server action as one transaction.
chg { f: folder, e: [[path, value, rev, flags]] }
Pushed to every subscriber once the write is committed (fast folders: at once).

Folders decide who gets in.

Every path belongs to a folder, and the folder’s mode sets the rules. Clients prove who they are with Ed25519 device keys the SDK creates in the browser. No passwords, no cookies.

Owners share their folders themselves with rifle.share: folder keys for a device or a service, grants per person, and invite links whose secret rides in the URL fragment. Each one can be taken back the same way.

Secret means access-controlled, not end-to-end encrypted. The server stores the data and could read it; the admin app only ever shows metadata and can wipe it.

  • PublicAnyone reads. Writes stay off until you set publicWrite or route them through an action.
  • PrivateEvery read and write needs a signature: the owner’s device, a folder key, or a grant.
  • SecretOnly the owner, from one of their devices, after a passkey check in the last ten minutes. Keys, grants and invites don’t apply, so a secret folder can never be shared.
Registration creates four folders per user
  • users/u_8fQ2xVprivatethe user’s own data
  • users/u_8fQ2xV/profilepublicanyone reads, the owner writes
  • users/u_8fQ2xV/secretsecretowner plus passkey step-up
  • users/u_8fQ2xV/serverprivatethe user reads, only the server writes

Fast folders for things that move.

Cursors, sliders, toggles, who’s online. A fast folder lives in memory: writes are acknowledged and broadcast at once, and bursts are coalesced per path (30 times a second by default), so a dragged slider doesn’t flood anyone.

  • Primitives and arrays of primitives only, up to 2 KB per value. persist: true snapshots to Redis every two seconds; persist: false never touches it.
  • ttlMs expires stale keys and onDisconnect cleans up when a tab closes. usePresence wraps both for cursors and avatars, and with publicWrite='own' nobody moves anyone else’s cursor.
rooms/lobbyfast, in memory
rooms/lobby/volume
72
rooms/lobby/playing
true
rooms/lobby/cursor/s_4kq
[412, 188, 204]
rooms/lobby/track
'side-b'
rifle.config.tsx
<Folder
  path='rooms'
  mode='public'
  publicWrite
  fast={{ persist: false, ttlMs: 15_000 }}
/>

Clients ask. The server decides.

Some state needs a referee: scores, inventories, turn order, a shared game world. Mark those paths as authority and clients can read them but never write them. Changes go through actions, which run on the server as one transaction.

Arguments are checked against the action’s schema, calls are rate-limited per user and per address, and a handler that throws writes nothing. The same action answers the socket and POST /v1/<folder>/@<action>.

Island.tsx
const dropPlate = useAction('demo/world', 'drop')
// Rejects on E_RATE or E_FULL; dropPlate.error keeps the reason
await dropPlate({ q: 1, r: 3, type: 'l' })
shell
curl -X POST https://rifle-api.struktur.online/v1/demo/world/@drop \
  -H 'content-type: application/json' \
  -d '{"q":1,"r":3,"type":"l"}'

The live demo runs on exactly this.

The demo is a shared hex island in 3D. Every plate anyone throws is one call to a server action, and lands for everyone at once, merges and all. No game server beside the database.

How a plate lands

  1. A visitor calls drop with a cell and a plate type, over the socket or as POST /v1/demo/world/@drop.
  2. The server checks the arguments against the schema, then the limits: four drops a second per player, forty per network address.
  3. The handler runs the island rules, seeded from the world’s own counter: the plate lands, reacts with the one below, merges with its neighbors.
  4. Every changed column is written in one commit and one broadcast. If the handler throws, nothing is written.
  5. One drop event with everything that happened reaches the other visitors only after the commit, so nobody sees a merge that didn’t happen. The visitor’s own browser ran the same rules with the same seed and showed the plate at once.
rifle.config.tsx
import { Action, Folder } from '@rifledb/server'
import { drop, dropRequestSchema, dropSeed, seaLevelFor } from '@rifledb/demo-world'
import type { DropEventData, Meta } from '@rifledb/demo-world'

<Folder path='demo/world' mode='public' authority={['**']}>
  <Action
    name='drop'
    args={dropRequestSchema} // { q, r, type: a tier-0 plate, n?: the caller's nonce }
    access='public'
    handler={(ctx, { q, r, type, n }) => {
      limitDrops(ctx) // 4 a second per player, 40 per network address
      const { columns, meta } = readWorld(ctx) // every col/<q>_<r>, and meta
      const seed = dropSeed(meta) // seedFrom(season, plates): browsers predict the same result
      const result = drop(columns, { q, r, type }, { seed })
      for (const [cell, column] of Object.entries(result.columns)) {
        ctx.op(`col/${cell}`, 'set', { value: column })
      }
      const plates = meta[1] + 1
      const next: Meta = [meta[0], plates, seaLevelFor(plates), meta[3]]
      ctx.op('meta', 'set', { value: next })
      // One event per drop: everything that happened, in order (landed, alchemy, merged…)
      const event: DropEventData = { seed, q, r, type, events: result.events, meta: next }
      if (n !== undefined) event.n = n // event data must be JSON: no undefined values
      ctx.emit('drop', event)
      return { placed: result.placed, seed, events: result.events, meta: next }
    }}
  />
</Folder>

The demo’s drop action as the server runs it. limitDrops and readWorld are two small helpers next to it.

See inside the server while it runs.

The admin app browses the data tree, manages folders, keys, grants and users, lists live connections and streams operations as they happen. Every change needs a passkey step-up.
A preview of the Metrics page with sample numbers.

A performance dashboard built in

The server samples itself once a second: ops per second and errors, ack and persist latency percentiles, connections and subscriptions, heap, event-loop delay and the Redis round trip. Every minute a record is kept for seven days, and the busiest folders and the live op stream are one click away.

A benchmark suite that knows its noise

A benchmark suite of micro benchmarks and workload scenarios (fan-out, presence, REST, actions, reconnects) compares two runs and calls a change slower only when it is larger than the noise. Its first baseline run is still to come, so there are no numbers to quote yet. How it measures.

rifleDB or gun.eco?

gun is a decentralized graph database: peers sync with each other and can encrypt data end to end. rifleDB makes the opposite trade: one server holds the truth and enforces the rules. Both are good tools, for different jobs.
rifleDB compared with gun.eco
AspectrifleDBgun.eco
ShaperifleDB: One authoritative server (Node.js and Redis) that you run.gun.eco: A peer-to-peer mesh of browsers and relay peers.
Who decidesrifleDB: The server orders every write with one revision counter. Compare-and-set with If-Match.gun.eco: Each peer merges updates with its conflict-resolution algorithm (HAM).
App rulesrifleDB: Schemas, authority paths and transactional actions, enforced on the server.gun.eco: Each peer decides what it accepts. There is no central referee for shared rules.
AccessrifleDB: Public, private and secret folders, with signing keys, grants and invite links.gun.eco: A public graph plus signed user spaces. Private data is shared by encrypting it to a key.
IdentityrifleDB: Ed25519 device keys kept in the browser, passkeys for step-up.gun.eco: SEA key pairs, commonly unlocked with an alias and a password.
OfflinerifleDB: Cached reads. Queued writes replay on reconnect, and the server can still reject them.gun.eco: Offline-first: peers keep working locally and merge when they meet again.
EncryptionrifleDB: HTTPS and WSS in transit. Secret folders are access-controlled, not end-to-end encrypted.gun.eco: Optional end-to-end encryption with SEA.
If the server is downrifleDB: Nothing syncs until it is back: there is one server. Clients keep cached values and queue writes. If only Redis is down, normal-folder writes are refused while fast folders keep working.gun.eco: No single point of failure: peers that can still reach each other keep syncing.
MaturityrifleDB: New. Not published to npm yet and without a production track record.gun.eco: An established open-source project, used in production for years, with a community around it.
Pick it whenrifleDB: Shared state needs one referee: multiplayer games, collaborative tools, live dashboards.gun.eco: You want no central server, or data that only the users’ own keys can read.

rifleDB does not encrypt data end to end and makes no zero-knowledge claims. If the server must not be able to read something, encrypt it on the client before you write it, or pick a tool built for that.

Get started in three steps.

Install the SDK, write one component, start the server: two browser tabs then share one counter.
  1. Install the SDK

    One package for the browser, React and Next.js. The npm release is coming; until then it installs from the rifleDB workspace. Setup has both ways.
    shell
    npm install rifledb
  2. Use a path

    useRifle subscribes to a path and returns its value and the ops to change it. Your tab updates first and rolls back if the server says no.
    src/App.tsx
    import { createRifle } from 'rifledb'
    import { RifleProvider, useRifle } from 'rifledb/react'
    
    const rifle = createRifle({ url: 'http://localhost:4000' })
    
    function Counter() {
      const [count, api] = useRifle('playground/counter')
      return <button onClick={() => api.inc()}>{count ?? 0} clicks</button>
    }
    
    export const App = () => (
      <RifleProvider client={rifle}>
        <Counter />
      </RifleProvider>
    )
  3. Run the server

    It ships with an open playground folder, so the counter works right away for an app on localhost:3000 or :5173. Open it in two tabs and click. Then declare your own folders, and add Redis when data should outlive a restart.
    shell
    # in the rifleDB workspace
    pnpm install
    pnpm -F @rifledb/server dev   # http://localhost:4000, in memory