The reactive backend
in one file.
TypeScript functions, a typed document database and live queries, running as a single binary on SQLite, Postgres or Turso. Copy it to a server and run it.
bun add github:abdullah-bl/onebase$ onebase serve --backup s3://my-bucket/backups onebase v0.1.0 functions 9 (channels:list, messages:list, messages:send, …) crons prune old messages database onebase_data/db.sqlite files onebase_data/files backups s3://my-bucket/backups every 60 min, keep 24 http http://localhost:8090 sync ws://localhost:8090/api/sync
Measured on Linux arm64, Bun 1.4.2, SQLite, with the example chat app.
Every client stays up to date. You don't write any sync code.
Clients subscribe to queries. When a mutation commits, onebase re-runs only the queries whose data changed and pushes the new results over WebSocket. Try it:
Simulated in your browser. Waiting for a mutation…
A schema, some functions, and a hook. That's the whole backend.
The API follows Convex: query, mutation, action, v.* validators, ctx.db. Everything is typed, from the schema to the React component.
import { v } from "onebase/values";
import { mutation, query } from "./_generated/server";
export const list = query({
args: { channel: v.id("channels") },
handler: (ctx, { channel }) =>
ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channel", channel))
.collect(),
});
export const send = mutation({
args: { channel: v.id("channels"), body: v.string() },
handler: async (ctx, args) => {
const user = await ctx.auth.getUserIdentity();
const author = user?.name ?? "anonymous";
return ctx.db.insert("messages", { ...args, author });
},
});
import { useMutation, useQuery } from "onebase/react";
import { api } from "../onebase/_generated/api";
export function Chat({ channel }) {
// Re-renders whenever anyone sends a message.
const messages = useQuery(api.messages.list, { channel });
const send = useMutation(api.messages.send);
return (
<>
{messages?.map((m) => (
<p key={m._id}><b>{m.author}</b> {m.body}</p>
))}
<button onClick={() => send({ channel, body: "hi" })}>
Say hi
</button>
</>
);
}
The parts every app ends up needing.
Fine-grained live queries
A new message in channel A never re-runs the query for channel B. Identical subscriptions share a single execution.
Real transactions
Mutations are atomic and serializable. Throw an error and none of the writes happen, even across several servers.
Node and npm everywhere
Every function runs in Bun. Import any package from a query, a mutation or an action. There's no "use node".
Typed end to end
The types flow from the schema to ctx.db, to api.*, to useQuery. Arguments are validated at runtime too.
Scheduler and crons
runAfter, runAt and cron jobs survive restarts. Each run happens exactly once, even with several instances.
File storage
Uploads stream to disk or to S3, R2 or MinIO, so big files never sit in memory. Downloads come from presigned URLs.
Sign-in built in
Passwords, GitHub, Google or any OAuth provider, with sessions, in an API shaped like Convex Auth. Or bring Clerk, Auth0 or any OIDC issuer.
SQLite, Postgres or Turso
Start on SQLite. Switch with --db when you need more servers, and your functions don't change.
Backups to S3
Consistent SQLite snapshots, gzipped and uploaded on a schedule. onebase restore brings them back.
Made for code written by people and by LLMs.
The API is the one models already know from Convex. It forgives the usual mistakes, and when something is wrong it says how to fix it.
.filter({ done: false })works, and it uses an index when one exists.- Every error names the problem and the fix: the wrong field, a missing index, an argument that doesn't match.
onebase doctorchecks a project in one command, and the docs come as llms-full.txt to give your assistant.
$ onebase doctor ✗ error posts.ts:2: Imports from the convex package instead of onebase. fix: Use "./_generated/server" and "onebase/values". ✗ error posts.ts:4: .filter() on a query gets a filter builder, not a document. fix: .filter(q => q.eq(q.field("x"), v)) or .filter({ x: v }) ✗ error schema.ts › posts.index("by_athor"): Field "athor" doesn't exist on "posts". fix: Known fields: title, author ⚠ warn Query on "posts" reads every document to apply .filter(). Add .index("by_author", ["author"]) and it will be used automatically.
From a $5 VPS to a fleet of servers.
One binary. Pick the database that fits your setup today, and change it later without touching your functions.
SQLite + S3 backups
The default. Reads take microseconds, there's no database to run, and hourly snapshots go to any S3-compatible storage.
onebase serve \ --backup s3://bucket/backups
Postgres
Run as many instances as you need behind a load balancer. Live queries are shared through LISTEN/NOTIFY, and mutations are serializable.
onebase serve \ --db postgres://…/app \ --files s3://bucket/files
Turso
SQLite semantics without running a database yourself, and still as many instances as you like.
TURSO_AUTH_TOKEN=… onebase serve \ --db libsql://my-db.turso.io
Same model. Different trade-offs.
Convex is a polished managed platform. onebase is for when you want to own the whole stack.
| Convex | onebase | |
|---|---|---|
| Hosting | Managed cloud, or a self-hosted backend | One binary you run anywhere |
| Runtime | V8 isolates; Node only in actions | Bun in every function |
| Database | Convex's own | SQLite, Postgres, MySQL or Turso |
| .filter() | Builder; scans the documents | Builder or object; uses indexes |
| Dashboard | Included | Included, at /_admin in the binary |
| Full-text search | Included | Included, same API |
| Vector search | Included | Included, same API |
| Porting | n/a | Change the imports; onebase doctor lists them |
Start with one file.
Clone the example chat app and have a live, typed backend running in a minute.