jotaBase
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.
Millions of records. Fast local interactions. Minimal sync overhead.
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.
Real-time presence, cursors and changes without turning every interaction into a database write.
// 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.
Real-time updates with roles, ownership, field permissions and state transitions built in.
// 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.
Everything you need to build and ship a production-ready application:
Built for AI-assisted development. Built for applications that need to feel instant.
One backend. One SDK. From prototype to production.
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");
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
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
npx jotabase unpublish notes puts it back.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,
})));
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();
npx jotabase register # use an email like [email protected]
npx jotabase create notes # private by default
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
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 } });
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();
// Pull everything since the last checkpoint.
await db.sync();
// And re-sync whenever the server changes.
db.subscribe(() => render());
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.