jotaBase

Create enterprise web apps that delight like games.

Build faster applications without building the backend around them.

jotaBase adapts synchronization to the workload — from real-time collaboration to massive datasets, with granular permissions and rules.

See what it can do

Massive dashboards, faster than ever

Millions of records. Fast local interactions. Minimal sync overhead.

See the code →
const db = jb.db("analytics");
await db.open();
await db.sync();          // 500,000 documents, pulled once as sealed segments

// Everything below runs on the device. No request, no spinner, no server index.
const revenue  = await db.query("orders").where({ region: "emea" }).sum("total");
const byStatus = await db.query("orders").countBy("status");
const page     = await db.query("orders").order("createdAt", "desc").page(1, 50).toArray();
const total    = await db.query("orders").count();   // ignores the page, sizes the pager

db.subscribe(() => refresh());   // later syncs pull only what changed since the checkpoint

The first sync streams sealed segments; every sync after it is a checkpoint away, so a dashboard over half a million rows costs one download and then almost nothing.

🎨Collaborative canvas, with minimal development effort

Real-time presence, cursors and changes without turning every interaction into a database write.

See the code →
// Dragging is local state. Nothing is written until the shape lands.
onDrag(shape => paint(shape));

await db.put({
  id: shape.id, collection: "shapes",
  data: { x: shape.x, y: shape.y, w: shape.w, h: shape.h, fill: shape.fill },
});                              // resolves locally, pushes in the background

db.subscribe(() => repaint());   // every other client repaints from its own local copy

Cursors and presence ride an ephemeral channel — broadcast and never stored, so they cost no write, no segment and no storage operation. A canvas generates around fifty of those for every change worth keeping, which is why they are a separate class of message rather than documents. Persisted shapes and change notifications, as above, are in the SDK today; the ephemeral channel is on its way.

📋Kanban with granular rules

Real-time updates with roles, ownership, field permissions and state transitions built in.

See the code →
// The rules. A dev may only move a card from todo to doing, and may touch
// no other field; a tester only doing → done; the PO edits the card itself.
{
  "cards": {
    "read":   ["po", "dev", "tester"],
    "create": ["po"],
    "delete": ["po"],
    "update": {
      "po":     { "fields": ["title", "description", "column", "order"] },
      "dev":    { "fields": ["column"], "transition": { "column": { "todo":  ["doing"] } } },
      "tester": { "fields": ["column"], "transition": { "column": { "doing": ["done"]  } } }
    }
  }
}
// The app. Same call as any other write — the rule is enforced server-side.
const res = await db.put({ id: "card-42", collection: "cards", data: { ...card, column: "doing" } });

// A batch returns one result per document, so a denial is visible per card.
const results = await db.putMany(reordered);
results.filter(r => !r.ok).forEach(r => revert(r));

Add owner: "assignee" to a constraint and a member can only move their own cards. Rules are per collection and opt-in, and the dashboard drafts them from a plain English description and simulates them against your real data before you enforce them.

Three of the shapes it is built for. An activity feed, a CRM over large records and a bulk import each get a different sync strategy — same backend, same SDK.


The backend AI coding loves.

Everything you need to build and ship a production-ready application:

Adaptive sync — optimized for the way your application works.
Authentication — accounts, app users and API keys.
Access rules — granular permissions with AI-assisted rule drafting.
Admin dashboard — manage data, users, rules and sync strategies.
Local-first — fast interactions with data close to the user.

Built for AI-assisted development. Built for applications that need to feel instant.

One backend. One SDK. From prototype to production.

Working with the data

Filtering, sorting, pagination and grouping all run on the device. A query returns rows — the document’s data with its id merged in.

const tasks = db.query("tasks");

// Filter — a predicate, or {field: value}. Repeated calls are ANDed.
await tasks.where({ team: "platform" }).where(t => t.points >= 5).toArray();

// Sort, with a tie-breaker. Missing values sort last, both directions.
await tasks.order("priority").order("dueAt", "desc").toArray();

// Paginate. count() ignores the page, so you can size the pager.
const rows  = await tasks.order("dueAt").page(3, 20).toArray();
const total = await tasks.count();

// Group and aggregate.
await tasks.countBy("status");          // { todo: 42, doing: 49, done: 49, ... }
await tasks.groupBy("assignee");
await tasks.where({ status: "done" }).sum("points");
await tasks.distinct("priority");

Sign your users in

The key treats all your users alike. Sign them in when they need roles, or rows only they can see.

const { token } = await jb.auth("notes").signUp({ email, password });
jb.setToken(token!);          // syncs now run as this user, with their roles

Roles are what access rules act on. Rules are per collection and opt-in — a collection with no rule stays open.

npx jotabase rules notes      # print what's enforced today

Share a database

A database is private until you say otherwise. Publish it and anyone can read it at @handle/name — no key to hand out and nothing to rotate.

npx jotabase alias ada        # claim your handle, once per account
npx jotabase publish notes    # now at @ada/notes
const db = createClient().db("@ada/notes");
await db.sync();              // no key, no account

Load data you already have

A CSV goes up from the terminal. Row ids come from the line number, so re-importing a corrected file updates rows instead of duplicating them.

npx jotabase import notes tasks.csv --collection tasks
// Or in the browser — putMany is put for a batch.
await db.putMany(rows.map((row, i) => ({
  id: `import-${i + 1}`,
  collection: "tasks",
  data: row,
})));

Try it first

npm install @jotabase/client dexie
import { createClient } from "@jotabase/client";

const jb = createClient();

const db = jb.db("@jotabase/sample");
await db.open();
await db.sync();                          // pulls 246 documents

const overdue = await db.query("tasks")
  .where(t => t.status !== "done")
  .order("dueAt")
  .limit(10)
  .toArray();

Quick start

  1. Create an account

    npx jotabase register     # use an email like [email protected]
  2. Create a database

    npx jotabase create notes # private by default
  3. Connect

    A publishable key is safe to ship in a frontend bundle — a secret key never is, and belongs on a server only. The URL defaults to api.jotabase.com, so the key is the only thing to pass.

    npx jotabase key notes    # prints pk_live_...
    import { createClient } from "@jotabase/client";
    
    const jb = createClient({ publishableKey: "pk_live_..." });  // real app: read it from .env
    
    const db = jb.db("notes");
    await db.open();          // loads the saved checkpoint
  4. Write

    The local write lands first and is pushed in the background, so it survives a dropped connection.

    await db.put({ id: "note-1", collection: "notes", data: { title: "Hello", done: false } });
  5. Read

    Queries run against the local copy, so they answer offline and without a round trip.

    const open = await db.query("notes")
      .where({ done: false })
      .order("title")
      .toArray();
  6. Sync

    // Pull everything since the last checkpoint.
    await db.sync();
    
    // And re-sync whenever the server changes.
    db.subscribe(() => render());

Status

jotaBase is in active development. Registration is open, the API is live, and the client is on npm as @jotabase/client — but it is pre-1.0 and the API may still change.