# Introduction onebase is a reactive backend you run yourself. You write your backend as TypeScript functions, store data in a typed document database, and clients subscribe to queries that update live when the data changes. It all ships as **one executable file**. ```ts // onebase/messages.ts 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: (ctx, args) => ctx.db.insert("messages", args), }); ``` ```tsx // In your React app: re-renders whenever anyone sends a message. const messages = useQuery(api.messages.list, { channel }); ``` ## Why onebase onebase follows [Convex](https://convex.dev)'s programming model and API, and makes different trade-offs: - **Small.** One Bun process with SQLite. It uses about 40 MB of memory at startup and needs no separate database server. - **Node and npm everywhere.** Queries, mutations and actions all run in Bun, so any npm package works in any function. There is no `"use node"` split. - **One file to deploy.** Copy the binary to a server and run it. The functions can sit next to it in a folder, or be compiled into it along with your frontend. - **Hard to misuse.** Errors explain how to fix the problem, `.filter()` picks indexes automatically, and `onebase doctor` catches common mistakes. This matters when an LLM writes the code. - **Grows with you.** Start on SQLite. When you need several instances, switch to Postgres, MySQL or Turso with one flag. ## What's included | | | |---|---| | [Schema](./schema.md) | Tables, validators, indexes, and checks on every write | | [Functions](./functions.md) | Queries, mutations, actions and internal functions, with argument validation | | [Database](./database.md) | `ctx.db`, indexes, filters, ordering and pagination | | [Search](./search.md) | Full-text search indexes, live like any query | | [Live queries](./client.md) | WebSocket subscriptions that update only when relevant data changes, with offline support | | [Client protocol](./client-protocol.md) | The wire contract every client implements — JS, [Swift](./swift.md), and yours | | [Swift client](./swift.md) | iOS and macOS apps: live queries, auth, optimistic updates, offline | | [Scheduling](./scheduling.md) | `runAfter` / `runAt`, plus cron jobs that survive restarts | | [File storage](./file-storage.md) | Uploads and downloads, on local disk or in S3 / R2 / MinIO | | [Auth](./auth.md) | Built-in password and OAuth sign-in with sessions, or tokens from Clerk, Auth0, WorkOS and any OIDC provider | | [HTTP actions](./http-actions.md) | Webhooks and custom HTTP endpoints | | [Components](./components.md) | Reusable packages with their own tables, such as the built-in [rate limiter](./rate-limiter.md) and [aggregate](./aggregate.md), called as `ctx.components.` | | [Databases](./databases.md) | SQLite, Postgres, MySQL or Turso, including multiple instances | | [Backups](./backups.md) | SQLite snapshots to S3, with restore | | [Dashboard](./dashboard.md) | Browse and edit data, run functions, and watch logs, jobs and files at `/_admin` | ## When to use something else onebase is a young project. Use Convex if you want a managed service and a team supporting it. Use onebase if you want to own the whole stack, run it on a small VPS or your own hardware, or need full Node.js in every function. Next: [Quick start](./getting-started.md). --- # Quick start ## Install onebase needs [Bun](https://bun.sh) 1.4 or later to develop with. The binary it builds runs anywhere, without Bun. **Option 1: download a binary.** Prebuilt binaries are attached to each [GitHub release](https://github.com/abdullah-bl/onebase/releases): ```bash curl -fsSL -o onebase https://github.com/abdullah-bl/onebase/releases/latest/download/onebase-linux-x64 ``` ```bash sudo install onebase /usr/local/bin/onebase && rm onebase && onebase --help ``` Replace `linux-x64` with `linux-arm64`, `darwin-arm64`, `darwin-x64` or `windows-x64.exe` for your platform. **Option 2: add it to a Bun project.** This gives you the CLI, the TypeScript types and the client libraries: ```bash bun add github:abdullah-bl/onebase ``` ```bash bunx onebase --help ``` **Option 3: build from source.** ```bash git clone https://github.com/abdullah-bl/onebase && cd onebase && bun install && bun run build:binary ``` The binary ends up in `dist/onebase`. ## Run the example The repository includes a chat app with channels, likes, image uploads, self-destructing messages and a cleanup cron. ```bash bun run example ``` Open http://localhost:8090 in two browser windows. Messages appear in both at once. ## Create a project ``` my-app/ onebase/ schema.ts tables and indexes tasks.ts your functions; the file name is the module name public/ index.html optional frontend, served at / ``` **1. Define the schema** in `onebase/schema.ts`: ```ts import { defineSchema, defineTable } from "onebase/server"; import { v } from "onebase/values"; export default defineSchema({ tasks: defineTable({ text: v.string(), done: v.boolean(), }), }); ``` **2. Write functions** in `onebase/tasks.ts`: ```ts import { v } from "onebase/values"; import { mutation, query } from "./_generated/server"; export const list = query({ args: {}, handler: (ctx) => ctx.db.query("tasks").order("desc").collect(), }); export const add = mutation({ args: { text: v.string() }, handler: (ctx, { text }) => ctx.db.insert("tasks", { text, done: false }), }); export const toggle = mutation({ args: { id: v.id("tasks") }, handler: async (ctx, { id }) => { const task = await ctx.db.get(id); if (task) await ctx.db.patch(id, { done: !task.done }); }, }); ``` **3. Start the dev server:** ```bash onebase dev ``` Dev mode generates `onebase/_generated/` (types for your schema and functions), restarts when a file changes, shows full error messages, and warns about slow queries. **4. Call your functions** from a page. No build step is needed: the server serves its own client. ```html ``` For React, see [Client](./client.md). ## Check your work ```bash onebase doctor ``` `doctor` loads every file and checks the schema, indexes, imports and common mistakes. It prints a fix for each problem it finds. ## Next steps - [Schema](./schema.md) and [Functions](./functions.md) cover the core API. - [Deployment](./deployment.md) shows how to run it in production. - Using an AI assistant? Give it the [LLM guide](./llm-guide.md). --- # Schema The schema lives in `onebase/schema.ts`. It declares your tables, the shape of their documents, and their indexes. Every write is checked against it, and your functions get types from it. ```ts import { defineSchema, defineTable } from "onebase/server"; import { v } from "onebase/values"; export default defineSchema({ users: defineTable({ name: v.string(), email: v.string(), role: v.union(v.literal("admin"), v.literal("member")), }).index("by_email", ["email"]), posts: defineTable({ author: v.id("users"), title: v.string(), tags: v.array(v.string()), published: v.boolean(), views: v.optional(v.number()), }) .index("by_author", ["author"]) .index("by_author_published", ["author", "published"]), }); ``` ## Validators | Validator | TypeScript type | |---|---| | `v.string()` | `string` | | `v.number()` | `number` (alias: `v.float64()`) | | `v.boolean()` | `boolean` | | `v.null()` | `null` | | `v.id("table")` | `Id<"table">` | | `v.literal("x")` | `"x"` | | `v.array(v.string())` | `string[]` | | `v.object({ a: v.number() })` | `{ a: number }` | | `v.record(v.string(), v.number())` | `Record` | | `v.union(a, b)` | `A \| B` | | `v.optional(x)` | field may be missing | | `v.any()` | anything | In function `args`, `v.id("table")` only accepts ids of that table (inside a component, the component's own table), so `ctx.db.get(id)` on a validated id can't reach another table. Values are stored as JSON. Store dates as numbers (`Date.now()`), not `Date` objects; onebase rejects a `Date` and tells you so. ## System fields Every document has `_id` and `_creationTime` (milliseconds). They're added automatically. Don't declare them, and don't pass them to `insert`. Ids carry a tag for their table, so `ctx.db.normalizeId("users", someString)` can check an id without a database lookup. ## Indexes ```ts defineTable({ ... }).index("by_author_published", ["author", "published"]) ``` - **Usage:** index names are yours to choose. Use them with `.withIndex()` (see [Database](./database.md)). - **Automatic use:** a `.filter()` on indexed fields uses the matching index automatically. - **Built-in index:** every table has `by_creation_time`. - **Changes:** indexes are created and dropped automatically at startup when `schema.ts` changes. - **Nested fields:** use dots, e.g. `["profile.country"]`. ## Search indexes ```ts defineTable({ ... }).searchIndex("search_body", { searchField: "body", filterFields: ["channel"] }) ``` Full-text search over one string field. See [Search](./search.md). ## Vector indexes ```ts defineTable({ ... }).vectorIndex("by_embedding", { vectorField: "embedding", dimensions: 1536, filterFields: ["cuisine"] }) ``` Nearest-neighbour search over an array of numbers, from actions with `ctx.vectorSearch`. See [Vector search](./vector-search.md). ## Changing a schema When `schema.ts` changes, onebase checks every stored document against it at startup, like a Convex deploy. If some don't match, the server refuses to start and lists them: ``` schema.ts doesn't match 2 existing documents: - people k57… : document.age is required (expected v.number()) - people k9a… : document.age is required (expected v.number()) ``` Either make the schema accept the data (`v.optional(...)`, `v.union(...)`), or fix the data: start with `{ schemaValidation: false }`, update the documents in a mutation or the [dashboard](./dashboard.md), then turn validation back on. The check runs only when the schema changes. ## Validation errors A bad write fails with a message that lists every problem: ``` Invalid "posts" document: - document.title must be v.string(), got number (42) - document.published is required (expected v.boolean()) - document.extra is not allowed (known fields: author, title, tags, published, views) ``` To turn validation off (for example while migrating data), pass `defineSchema({ ... }, { schemaValidation: false })`. ## Without a schema `schema.ts` is optional. Without it, any table accepts any document and `ctx.db` is untyped. `onebase doctor` will remind you to add one. --- # Functions Each file in `onebase/` is a module, and each exported function becomes an endpoint named `module:export`. For example, `onebase/messages.ts` exporting `list` becomes `messages:list`, which clients reference as `api.messages.list`. Subfolders nest: `onebase/admin/users.ts` → `api.admin.users.fn`. Import the builders from `./_generated/server`. They're typed for your schema. ## Queries Queries read data. They're read-only, see a consistent snapshot, and clients can subscribe to them. ```ts import { v } from "onebase/values"; import { query } from "./_generated/server"; export const byAuthor = query({ args: { author: v.id("users") }, handler: async (ctx, { author }) => { return await ctx.db .query("posts") .withIndex("by_author", (q) => q.eq("author", author)) .order("desc") .take(20); }, }); ``` A subscribed query re-runs only when a write could change its result. Identical subscriptions (same function, arguments and user) share a single execution. ## Mutations Mutations write data in a transaction: either all of their writes happen, or none do. ```ts import { ConvexError, v } from "onebase/values"; import { mutation } from "./_generated/server"; export const publish = mutation({ args: { id: v.id("posts") }, handler: async (ctx, { id }) => { const post = await ctx.db.get(id); if (!post) throw new ConvexError("Post not found"); await ctx.db.patch(id, { published: true }); }, }); ``` Inside a mutation you can use: - `ctx.runQuery` and `ctx.runMutation`, which run in the same transaction. - `ctx.scheduler`, whose jobs only happen if the mutation commits. - `ctx.storage`. ## Actions Actions are for side effects: calling APIs, sending email, running AI models. They don't have `ctx.db`. Read with `ctx.runQuery` and write with `ctx.runMutation`. ```ts import { v } from "onebase/values"; import { internal } from "./_generated/api"; import { action } from "./_generated/server"; import OpenAI from "openai"; // any npm package works export const summarize = action({ args: { id: v.id("posts") }, handler: async (ctx, { id }) => { const post = await ctx.runQuery(internal.posts.get, { id }); const summary = await new OpenAI().responses.create({ model: "gpt-5", input: post!.title }); await ctx.runMutation(internal.posts.setSummary, { id, summary: summary.output_text }); }, }); ``` Every function runs in Bun, so `node:*` modules and npm packages work in queries and mutations too. **`fetch()` is the exception: only actions can call it.** A query re-runs whenever its data changes, and a mutation holds the write lock and may be retried when it conflicts with another instance (see [Databases](./databases.md)), so outside calls belong in actions. Calling `fetch()` from a query or mutation fails with an error that says so. ## Internal functions `internalQuery`, `internalMutation` and `internalAction` can't be called by clients, only from other functions, the scheduler and crons. Reference them through `internal` instead of `api`: ```ts import { internal } from "./_generated/api"; await ctx.scheduler.runAfter(0, internal.emails.send, { to }); ``` ## Arguments and return values `args` is validated before the handler runs. A bad call fails with a clear message, and the handler's `args` parameter is fully typed. ``` Invalid arguments for "messages:send": - args.body is required (expected v.string()) ``` Use `args: {}` for functions that take no arguments. `onebase doctor` warns about functions without `args`, since they accept any input. You can also declare `returns: v.array(...)` to check what a function returns. ## Errors `throw new ConvexError("message")`, or `new ConvexError({ code: "LIMIT", max: 5 })`, for errors the caller should see. The client receives the message and the `data`. Any other error appears as `Server Error` to clients in production; the full message is logged on the server. In `onebase dev` every message is shown. ## Time limits A query or mutation may run for 10 seconds, and an action (including an HTTP action) for 10 minutes. A call that runs longer fails with `notes:list didn't finish within the query time limit (10s).` A mutation's writes are rolled back. JavaScript can't stop a function in the middle of an `await`, so the function itself keeps running until it next yields. Anything it then tries fails with the same error, so it can't read or write any more, call other functions, or schedule them. Code that never yields, such as `while (true) {}`, can't be stopped this way and blocks the whole server. The limit counts from when the call gets its database snapshot (a query) or the write lock (a mutation), not from when it was queued. A mutation called from a mutation shares its caller's limit; one called from an action gets its own. Change the limits with `ONEBASE_QUERY_TIMEOUT`, `ONEBASE_MUTATION_TIMEOUT` and `ONEBASE_ACTION_TIMEOUT`, e.g. `30s`, `2m`, or `off`. Keep mutations short: while one runs, every other write waits for it. ## The context object | | query | mutation | action | |---|:-:|:-:|:-:| | `ctx.db` | read | read + write | – | | `ctx.auth.getUserIdentity()` | ✓ | ✓ | ✓ | | `ctx.storage` | read | + upload, delete | + store, get | | `ctx.scheduler` | – | ✓ (transactional) | ✓ | | `ctx.runQuery` | – | ✓ | ✓ | | `ctx.runMutation` | – | ✓ | ✓ | | `ctx.runAction` | – | – | ✓ | --- # Database ## Reading ```ts const post = await ctx.db.get(id); // document or null const all = await ctx.db.query("posts").collect(); // every document, oldest first ``` A query is built from these steps, in order: ```ts ctx.db .query("posts") // 1. table .withIndex("by_author", (q) => q.eq("author", userId)) // 2. optional: index range .order("desc") // 3. optional: "asc" (default) or "desc" .filter((q) => q.gt(q.field("views"), 100)) // 4. optional: more conditions .take(10); // 5. collect() | take(n) | first() | unique() | paginate(opts) ``` | Ending | Returns | |---|---| | `.collect()` | every match | | `.take(n)` | up to `n` matches | | `.first()` | the first match or `null` | | `.unique()` | the only match or `null`; throws if more than one matches | | `.paginate({ numItems, cursor })` | `{ page, isDone, continueCursor }` | You can also loop with `for await (const post of ctx.db.query("posts")) { ... }`. ## Indexes `withIndex` reads a slice of an index. Call `.eq()` on the index's fields in order. After that, you may add one lower bound (`gt` / `gte`) and one upper bound (`lt` / `lte`) on the next field: ```ts // index "by_author_published": ["author", "published"] .withIndex("by_author_published", (q) => q.eq("author", userId).eq("published", true)) // every table has "by_creation_time" .withIndex("by_creation_time", (q) => q.gt("_creationTime", Date.now() - 86_400_000)) ``` Results come back in index order: by the index fields, then by `_creationTime`. Because every index ends with `_creationTime`, you can also put a range on it after the equality conditions: ```ts // messages in a channel from the last hour .withIndex("by_channel", (q) => q.eq("channel", channel).gt("_creationTime", Date.now() - 3_600_000)) ``` If you get the order wrong or use a field the index doesn't have, the error says which fields the index has and in what order. ## Filters `.filter()` accepts either form: ```ts .filter({ published: true, author: userId }) // shorthand: field equals value .filter((q) => q.and(q.eq(q.field("published"), true), q.gte(q.field("views"), 10))) ``` Builder operators: - **Comparison:** `eq`, `neq`, `lt`, `lte`, `gt`, `gte` - **Logic:** `and`, `or`, `not` - **Arithmetic:** `add`, `sub`, `mul`, `div`, `mod`, `neg` - **Fields:** `q.field("a.b")` reads a (nested) field. **Filters use indexes automatically.** When an index matches the fields you filter on, the database uses it. You don't have to use `withIndex`. In dev mode, a filter that has to read the whole table logs a warning with the index to add: ``` warn Query on "posts" reads every document to apply .filter(). Add .index("by_author", ["author"]) to "posts" in schema.ts and it will be used automatically. ``` The callback receives a filter builder, not a document. For logic the builder can't express, filter in JavaScript: ```ts const posts = (await ctx.db.query("posts").collect()).filter((p) => p.title.includes("onebase")); ``` For full-text search, use `.withSearchIndex(...)` instead of `.withIndex(...)`. See [Search](./search.md). ## Pagination ```ts import { paginationOptsValidator } from "onebase/server"; export const feed = query({ args: { paginationOpts: paginationOptsValidator }, handler: (ctx, { paginationOpts }) => ctx.db.query("posts").order("desc").paginate(paginationOpts), }); ``` Pass `{ numItems: 20, cursor: null }` for the first page, then `cursor: continueCursor` for the next one. `numItems` must be a non-negative integer, and a page holds at most 8192 documents: ask for more and you get 8192 with `isDone: false`. Cursors are stable when documents are inserted. In React, `usePaginatedQuery` handles this for you and keeps every loaded page live (see [Client](./client.md)). ## Writing (mutations only) ```ts const id = await ctx.db.insert("posts", { author, title, tags: [], published: false }); await ctx.db.patch(id, { title: "New title", views: undefined }); // shallow merge; undefined removes a field await ctx.db.replace(id, { author, title, tags: [], published: true }); // replace the whole document await ctx.db.delete(id); ``` Every write is validated against the schema. Writes in one mutation are atomic, and if the mutation throws, none of them happen. --- # Search A search index finds documents by the words in one string field, best matches first. The API is the same as Convex's. ## Define a search index ```ts // onebase/schema.ts messages: defineTable({ channel: v.id("channels"), body: v.string(), }).searchIndex("search_body", { searchField: "body", // the text to search (a string field) filterFields: ["channel"], // optional: fields you can narrow results by }), ``` The index is created at startup and filled from the documents already in the table. After that, every insert, patch, replace and delete keeps it up to date in the same transaction. Removing it from `schema.ts` drops it. ## Search ```ts export const search = query({ args: { channel: v.id("channels"), text: v.string() }, handler: async (ctx, { channel, text }) => { return await ctx.db .query("messages") .withSearchIndex("search_body", (q) => q.search("body", text).eq("channel", channel)) .take(10); }, }); ``` - **Matching:** a document matches if its field contains **any** of the words, ignoring case. The **last word also matches as a prefix**, so results appear as the user types: `"onebase sea"` finds "search". - **Order:** best matches first. Documents with more of the words, rarer words and shorter text rank higher. Ties go to newer documents. - **`.eq(field, value)`:** keeps only documents where a `filterFields` field equals the value. Chain several to combine them. - **After `withSearchIndex`** you can use `.filter(...)` and any ending: `.take(n)`, `.first()`, `.unique()`, `.collect()` or `.paginate(...)`. `.order()` isn't available, because results are ordered by relevance. - **Live:** a search query is reactive like any other. A subscription re-runs when a document in the table changes, or only documents with the same filter values when you use `.eq()`. ## Pagination ```ts export const searchPage = query({ args: { text: v.string(), paginationOpts: paginationOptsValidator }, handler: (ctx, { text, paginationOpts }) => ctx.db.query("messages").withSearchIndex("search_body", (q) => q.search("body", text)).paginate(paginationOpts), }); ``` `usePaginatedQuery` works with it as usual. Cursors are positions in the relevance order, so a document added while someone is paging can shift later pages by one. ## Limits | | | |---|---| | Results per query | 1024 (`.collect()` returns at most this many) | | Words used from the search text | the first 16 | | `filterFields` per index | 16 | | Empty search text (or only punctuation) | no results | ## Language support Words are split on anything that isn't a letter, digit or combining mark, so text in any script works. There's no stemming: "running" doesn't match "run", though typing "run" finds "running" through the prefix rule. - **SQLite, Turso and MySQL** ignore accents: "cafe" finds "café". - **Postgres** keeps them: "cafe" and "café" are different words. - **MySQL** skips words shorter than 3 characters and common stopwords such as "the". ## How it's stored - **SQLite / Turso:** one [FTS5](https://www.sqlite.org/fts5.html) table per search index, ranked with BM25. It lives in the same database file, so [backups](./backups.md) include it. - **Postgres:** a GIN index on `to_tsvector('simple', field)` over the documents table, ranked with `ts_rank`. Writes need no extra work. - **MySQL:** a side table per search index with a `FULLTEXT` index, searched in boolean mode. `onebase doctor` checks that `searchField` and `filterFields` exist and that the search field is a string. --- # Vector search A vector index finds the documents whose vector (an embedding, say) is closest to a query vector, by cosine similarity. The API is the same as Convex's. ## Define a vector index ```ts // onebase/schema.ts foods: defineTable({ description: v.string(), cuisine: v.string(), embedding: v.optional(v.array(v.float64())), }).vectorIndex("by_embedding", { vectorField: "embedding", // an array of numbers dimensions: 1536, // every vector's length, from 2 to 4096 filterFields: ["cuisine"], // optional: fields you can narrow results by }), ``` The index is created at startup and filled from the documents already in the table. After that, every insert, patch, replace and delete keeps it up to date in the same transaction. Removing it from `schema.ts` drops it, and changing `dimensions` rebuilds it. A document without the field isn't indexed. Writing any other value than an array of `dimensions` numbers fails the mutation with an error that names the field and index. ## Search Like Convex, vector search runs in actions, where you'll usually call an embedding API first: ```ts export const similarFoods = action({ args: { text: v.string(), cuisine: v.optional(v.string()) }, handler: async (ctx, { text, cuisine }) => { const vector = await embed(text); // your embedding API const results = await ctx.vectorSearch("foods", "by_embedding", { vector, limit: 16, filter: cuisine ? (q) => q.eq("cuisine", cuisine) : undefined, }); // [{ _id, _score }, ...], most similar first return await ctx.runQuery(internal.foods.fetchResults, { ids: results.map((r) => r._id) }); }, }); ``` - **Results:** `{ _id, _score }` for each match, most similar first. `_score` is the cosine similarity, from -1 (opposite) to 1 (same direction). Load the documents with `ctx.runQuery()`. - **`limit`:** from 1 to 256, default 10. - **`filter`:** `q.eq(field, value)` on a `filterFields` field, and `q.or(...)` to match any of several: `(q) => q.or(q.eq("cuisine", "French"), q.eq("cuisine", "Thai"))`. - **The query vector** must have `dimensions` numbers and can't be all zeros. ## How each database runs it | Database | How | Scale | |---|---|---| | **SQLite** | A side table of float32 vectors per index, with a copy in memory that worker threads compare with WebAssembly SIMD. Every matching vector is compared, so results are exact, up to float32 rounding when scores nearly tie. | Linear: about 2 ms of work per search over 10k vectors of 1536 dimensions, and searches run side by side. See [Memory and threads](#memory-and-threads-on-sqlite). | | **Turso / libSQL** | The same side table, compared with libSQL's built-in `vector_distance_cos`. Exact, and the comparison runs in the database. | Linear, like SQLite. | | **Postgres** | An HNSW index from [pgvector](https://github.com/pgvector/pgvector) on the documents table, so writes need no extra work. onebase runs `CREATE EXTENSION IF NOT EXISTS vector`; the database user needs the right to do that, or create the extension yourself. With pgvector 0.8+, filtered searches keep scanning until the limit is filled. | Large tables. HNSW is approximate: results are very close but not guaranteed to be the exact nearest. Indexes over 2000 dimensions are searched without an index. | | **MySQL** | MySQL Community has no vector distance function, so onebase reads the matching documents' vectors and ranks them in the server. Exact. | Small tables only: every search reads every matching vector. | The SQLite side tables are stored in the same file as the documents, so [backups](./backups.md) include them, and vectors are stored the way libSQL stores `F32_BLOB` values. ## Memory and threads on SQLite An index is loaded into memory by the first search that uses it, so indexes nobody searches cost nothing, and it's dropped again after 30 minutes without a search. Writes reach it in the same order they commit: a search sees every committed write and nothing that was rolled back. Vectors take `dimensions × 4` bytes each, about 60 MB for 10k vectors of 1536 dimensions, and room is added by doubling. All indexes together may use `ONEBASE_VECTOR_MEMORY_MB` (default `2048`). An index that doesn't fit is searched in SQL instead, which gives the same results much more slowly. Set it to `0` to always search in SQL. Searches of larger indexes (over about a million numbers: 700 vectors of 1536 dimensions) run on worker threads, so they don't hold up other requests or each other. The workers start with the first such search and stop when no index is left in memory. There are up to 8, one less than the CPU cores; `ONEBASE_VECTOR_WORKERS` sets how many, and `0` keeps searches on the main thread. Writes and searches don't wait for each other: a search compares the vectors as they were when it started, and sees every write committed before that. `onebase doctor` checks that `vectorField` and `filterFields` exist and that the vector field holds arrays of numbers. --- # Client ## Generated API `onebase dev` (or `onebase codegen`) writes `onebase/_generated/api.ts`. Import `api` from it for type-checked function references: ```ts import { api } from "../onebase/_generated/api"; await client.mutation(api.tasks.add, { text: "Hi" }); // args and return type are checked ``` Without the generated types you can use `anyApi` (untyped) or plain strings like `"tasks:add"`. ## Connecting to your backend When onebase serves your frontend (from `public/`), the client connects to the same address and you don't pass a URL. When the frontend is hosted somewhere else (Vercel, Netlify, a CDN, a mobile app), pass the backend's URL: ```ts const client = new OnebaseClient("https://api.example.com"); ``` - **WebSocket:** derived automatically (`https://api.example.com` → `wss://api.example.com/api/sync`). - **Per environment:** keep the URL in an environment variable, e.g. `new OnebaseClient(import.meta.env.VITE_ONEBASE_URL)`. - **CORS:** any origin can call the API by default. Restrict it with `--cors https://app.example.com` (comma-separate several origins). - **File URLs:** start the server with `--url https://api.example.com`, so `ctx.storage.getUrl()` returns public addresses instead of `localhost`. ## Browser ```ts import { OnebaseClient } from "onebase/browser"; const client = new OnebaseClient("https://api.example.com"); // defaults to the page's origin const stop = client.onUpdate(api.tasks.list, {}, (tasks) => render(tasks), (err) => showError(err)); await client.mutation(api.tasks.add, { text: "Write docs" }); const result = await client.action(api.ai.summarize, { id }); const once = await client.query(api.tasks.list, {}); stop(); // unsubscribe ``` The client uses one WebSocket: - It reconnects with backoff and re-subscribes automatically. - `client.onConnectionChange(state => ...)` reports `"connecting"`, `"open"` or `"closed"`. - If the connection drops before a call is answered, the call rejects instead of being retried silently, so you never run a mutation twice by accident. **Without a bundler:** every onebase server serves the client as an ES module: ```html ``` ## React ```tsx import { OnebaseClient, OnebaseProvider, useMutation, useQuery } from "onebase/react"; import { api } from "../onebase/_generated/api"; const client = new OnebaseClient(import.meta.env.VITE_ONEBASE_URL); export function App() { return ( ); } function Tasks() { const tasks = useQuery(api.tasks.list); // undefined while loading const add = useMutation(api.tasks.add); if (tasks === undefined) return

Loading…

; return ( <> {tasks.map((t) =>

{t.text}

)} ); } ``` | Hook | | |---|---| | `useQuery(ref, args \| "skip")` | live result; `undefined` while loading; errors throw to the nearest error boundary | | `useMutation(ref)` | returns `(args) => Promise` | | `useAction(ref)` | returns `(args) => Promise` | | `usePaginatedQuery(ref, args, { initialNumItems })` | `{ results, status, isLoading, loadMore(n) }`; every loaded page stays live; errors throw to the nearest error boundary | | `useConnectionState()` | `"connecting" \| "open" \| "closed"` | | `useOnebaseAuth()` | `{ isLoading, isAuthenticated, error }` (also exported as `useConvexAuth`) | | `useAuthActions()` | `{ signIn, signOut }` for built-in sign-in | **Porting from Convex:** the aliases `ConvexProvider` and `ConvexReactClient` are exported too, so code written for Convex works after changing the import path. ## Optimistic updates An optimistic update shows a mutation's expected effect immediately, before the server replies. When the mutation finishes, the server's real result takes its place. If the mutation fails, the update is undone. ```tsx const like = useMutation(api.posts.like).withOptimisticUpdate((store, { id }) => { const posts = store.getQuery(api.posts.list, {}); if (posts !== undefined) { store.setQuery(api.posts.list, {}, posts.map((p) => (p._id === id ? { ...p, likes: p.likes + 1 } : p))); } }); ``` Without React, pass it as an option: ```js await client.mutation(api.posts.like, { id }, { optimisticUpdate: (store, { id }) => { /* same as above */ } }); ``` The `store` covers the queries this client is subscribed to: | | | |---|---| | `store.getQuery(ref, args)` | current result, or `undefined` if not subscribed or loading | | `store.getAllQueries(ref)` | `[{ args, value }]` for every subscribed instance of the query | | `store.setQuery(ref, args, value)` | replaces the result until the mutation finishes; `undefined` shows it as loading | - **Don't modify results in place.** Build new arrays and objects, as above. - **It may run more than once:** first when you call the mutation, then again on top of each new server result that arrives while the mutation is in flight. Keep it a pure function of `store` and `args`. - **No flicker:** the server sends a mutation's result together with the query updates that include its writes, and the client applies them in one step. ## Offline Opt in, and the client keeps working through drops in connectivity and reloads while offline: ```ts const client = new OnebaseClient("https://api.example.com", { offline: true }); ``` | | | |---|---| | Cached results | the newest 100 query results (set `offline: { maxCachedQueries }`) are kept in `localStorage`, so a reload while offline shows them immediately; live data replaces them when it arrives | | Queued mutations | mutations made while disconnected are stored and sent, in the order they were made, once the connection returns — across reloads too | | `client.queuedMutations` | how many are still queued | | `client.clearQueuedMutations()` | drops them (the promises from when they were made reject) | Details worth knowing: - **Each queued mutation is sent at most once per session.** One that was sent but lost with the connection is never silently re-sent — it may or may not have run — matching how every other call behaves. Make such mutations idempotent if that matters, or send them from an action with its own deduping. - **Cached results belong to the signed-in user.** With a token configured they're held back until the server confirms the same user is asking, and the cache is cleared when the user changes: one user never sees another's data. - **Queued mutations belong to the user who made them.** They're sent only once the server confirms that same user is asking; if someone else is signed in by then they're dropped, never sent as the new user. (One made before the server ever confirmed who is asking is sent for whoever is confirmed first.) - **Optimistic updates don't survive a reload.** They cover what happens while the page is open. - Pass `offline: { storage }` to store elsewhere (any object with `getItem`/`setItem`/`removeItem`); in browsers the default is `localStorage`. The [Swift client](./swift.md) works the same way with a directory on disk. ## Authentication With [built-in sign-in](./auth.md#built-in-sign-in): ```ts await client.signIn("password", { flow: "signIn", email, password }); await client.signIn("github"); // goes to GitHub and comes back signed in client.authState; // { isLoading, isAuthenticated, error } client.onAuthChange((state) => render(state)); await client.signOut(); ``` With an external provider: ```ts client.setAuth(async () => await getTokenFromYourAuthProvider()); // called on every (re)connect client.clearAuth(); ``` Either way, the server re-runs your subscriptions as that user. See [Auth](./auth.md). ## Scripts and servers `OnebaseHttpClient` uses plain HTTP, with no WebSocket and no subscriptions: ```ts import { OnebaseHttpClient } from "onebase/browser"; const http = new OnebaseHttpClient("http://localhost:8090"); http.setAuth(token); await http.mutation(api.tasks.add, { text: "From a cron script" }); ``` ## HTTP API Any language can call functions over HTTP: ```bash curl -X POST http://localhost:8090/api/mutation \ -H "Content-Type: application/json" \ -d '{"path": "tasks:add", "args": {"text": "From curl"}}' ``` ```json { "status": "success", "value": "k3m8x…" } ``` | Endpoint | | |---|---| | `POST /api/query`, `/api/mutation`, `/api/action` | `{ path, args }` → `{ status, value }` or `{ status: "error", errorMessage, errorData }` | | `POST /api/run` | same, for any function type | | `GET /api/health` | `{ ok, functions, subscriptions }` | | `WS /api/sync` | the live protocol used by the clients ([spec](./client-protocol.md)) | Send `Authorization: Bearer ` to call as a user. Writing a client for another platform? The [client protocol spec](./client-protocol.md) is the contract; the Swift client ([docs](./swift.md)) is a full example, with offline support and optimistic updates. --- # Client protocol One documented wire contract, one SDK per platform. The JS client (`onebase/browser`) and the Swift client (`clients/swift` in the repository) both speak it; this page is everything a new client needs — an Android one, say — to work with any onebase server. ## WebSocket: `/api/sync` One WebSocket per client carries the auth token, live query subscriptions and function calls. Message boundaries are frames; each message is one JSON object. Send the auth message before anything else on every (re)connect: the server runs subscriptions under the identity in effect when they (re)start. The server closes idle connections after 120 seconds, so a client pings (WebSocket ping frames are fine; the server answers them) at least once a minute, reconnects with backoff, and re-sends its auth token, subscriptions and queued calls in that order. ### Client → server ```json { "type": "auth", "token": "ob_s_…" } ``` The session token (or any token your `auth.config.ts` accepts), or `null` to be anonymous. Send once per connection, before subscriptions and calls. ```json { "type": "sub", "id": 1, "path": "notes:list", "args": {} } ``` Subscribe to a query. `id` is a number the client picks; every update for this subscription carries it. Re-sending with the same `id` replaces the subscription. `path` is `":"` of a public query; `args` is the function's arguments object. ```json { "type": "unsub", "id": 1 } ``` ```json { "type": "call", "requestId": 7, "kind": "mutation", "path": "notes:add", "args": { "text": "Hi" } } ``` `kind` is `"query"`, `"mutation"` or `"action"`. `requestId` is a number the client picks; the answer carries it back. ### Server → client ```json { "type": "auth", "ok": true, "identity": { "subject": "m17…", "email": "…" } } ``` `identity` is `null` when anonymous. On failure: `{ "type": "auth", "ok": false, "error": "…" }` with `ended: true` when the token is gone for good (signed out, expired, deleted) — forget it and show signed-out state. Any other failure may be transient: keep the token and retry on the next reconnect. ```json { "type": "update", "id": 1, "ok": true, "value": [ … ] } ``` A subscription's new result. Errors: `{ "type": "update", "id": 1, "ok": false, "error": "…", "data": … }` — keep the subscription; it recovers when the problem is fixed (a deploy adds the missing function, the data matches the schema again). ```json { "type": "result", "requestId": 7, "ok": true, "value": "m57…" } ``` The answer to a call. Errors carry `error` (and optional `data`) instead of `value`. ```json { "type": "transition", "updates": [ …updates… ], "results": [ …results… ] } ``` A mutation's result and the query updates that include its writes, in one message. Apply the updates and the result together: that's what lets a client drop an optimistic update at exactly the moment the real data arrives, with no flicker. ### Semantics a client should keep - **Identical subscriptions share results.** Two subscriptions to the same (function, args, user) get the same values; the server already runs the query once per unique subscription. - **Calls are at-most-once per send.** If the connection drops before an answer, the call may or may not have run; report it, never re-send it silently. Offline queues (below) persist a mutation *until it is first sent*, then follow this same rule. - **Errors from `ConvexError` are user-facing;** anything else the server may replace with `Server Error` outside dev mode. - **Ordering:** messages are processed in order; a client that sends `auth` → `sub`s → queued calls on reconnect sees them applied in that order. ## HTTP | Endpoint | | |---|---| | `POST /api/query`, `/api/mutation`, `/api/action`, `/api/run` | `{ path, args }` → `{ status: "success", value }` or `{ status: "error", errorMessage, errorData }` | | `GET /api/health` | `{ ok, functions, subscriptions, deployment }` | | `POST /api/auth/signin`, `/api/auth/signout` | built-in auth: see [Auth](./auth.md#http-api) | | `GET /api/storage/` | file downloads | `Authorization: Bearer ` authenticates any request. `429` responses carry a `Retry-After` header — the server's `--rate-limit`. ## Offline support (a client convention, not a protocol feature) The clients implement offline the same way, so apps behave alike on every platform: 1. **Query cache.** Keep the newest N results for subscribed (path, args) pairs, keyed by function path and arguments. On a cold start, show the cached result immediately (it's stale), then the live value when it arrives. With auth configured, hold a cached result back until the server confirms the same user is asking, and clear the cache when the user changes: cached data belongs to one user. 2. **Mutation outbox.** Persist mutations made while disconnected, and send them in the order they were made once the connection returns — across restarts. An entry is removed when the server answers. One lost mid-send is dropped with a warning, never silently re-sent. 3. **Optimistic updates** stay in memory: they cover what happens while the app is running. An Android client that follows this page will match the JS and Swift clients feature for feature. --- # Swift client `OnebaseClient` is the onebase SDK for Swift apps (iOS 15+ / macOS 12+, no package dependencies): one WebSocket carries live queries, calls and the auth token. It lives in [`clients/swift`](https://github.com/abdullah-bl/onebase/tree/main/clients/swift) in the repository. Add it by URL — released versions are published to [onebase-swift](https://github.com/abdullah-bl/onebase-swift), because SwiftPM needs the package at the root of a repository: ```swift // Package.swift dependencies: [ .package(url: "https://github.com/abdullah-bl/onebase-swift.git", from: "0.1.0"), ] // target: .product(name: "OnebaseClient", package: "onebase-swift") ``` Working from a clone of onebase, or before the first release? Use the folder directly: `.package(path: "../onebase/clients/swift")`, with `package: "swift"` in the product. ## Connect and use ```swift import OnebaseClient let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let client = OnebaseClient(url: "https://api.example.com", offlineDirectory: documents) client.onConnectionChange = { state in } // .connecting / .open / .closed // Live queries: onUpdate runs with every new result, on the main queue. var subscription: OnebaseSubscription? subscription = client.subscribe("notes:list", [:]) { value in let notes = value?.array ?? [] // render them } subscription?.cancel() // when the view goes away let id = try await client.mutation("notes:add", ["text": "Hello"]) let best = try await client.query("notes:best", [:]) try await client.action("emails:sendWelcome", ["to": "someone@example.com"]) ``` Values are [`OnebaseValue`](#onebasevalue): a JSON value. Build arguments from literals (`["text": "Hello", "count": 3]`) or from your own `Codable` types (`try OnebaseValue(note)`); read results with `value?.array`, `value?["user", "name"]?.string`, `value?.int`, and so on. ## Optimistic updates ```swift try await client.mutation("notes:add", ["text": "Hello"]) { store in let current = store.get("notes:list")?.array ?? [] store.set("notes:list", [:], .array(current + [["text": "Hello"]])) } ``` The closure sees the subscribed queries' current results and overrides them until the mutation finishes; the server's real results then take over (the server sends a mutation's result and its query updates together, so there's no flicker). Setting a query to `nil` shows it as loading. Keep it a pure function of `store` — it may run again on top of results that arrive while the mutation is in flight. ## Auth Built-in password sign-in goes over HTTP; the token is kept in the Keychain and sent on every (re)connect: ```swift client.onAuthChange = { signedIn in } // false when the token stops checking out let token = try await client.signInPassword(email: "someone@example.com", password: "correct horse") let codeFlowToken = try await client.signInPassword(email: "…", password: "…", flow: "signUp") // nil: a code was emailed try await client.signOut() ``` Any other token (an external provider's) works with `client.setToken("…")`. Pass your own `OnebaseTokenStore` to store it somewhere else. ## Offline With an `offlineDirectory` (recommended), the client keeps working through drops in connectivity and cold starts while offline: - the newest 100 query results (`OfflineStore(directory:maxCachedQueries:)`) are cached on disk and shown while disconnected, then replaced by live data; - mutations made while disconnected are queued on disk and sent, in order, once the connection returns — across app restarts; - `client.queuedMutationCount` and `client.clearQueuedMutations()` manage the queue; failures of mutations queued by an earlier run surface through `onQueuedMutationFailure`; - each queued mutation is sent at most once per run — one lost with the connection is dropped, never silently re-sent; - cached results are held back until the server confirms the same user is asking, and cleared when the user changes. See [Offline](./client.md#offline) for the behavior all clients share, and [Client protocol](./client-protocol.md) for the wire contract. ## OnebaseValue ```swift public enum OnebaseValue { case null, bool(Bool), int(Int64), double(Double), string(String) case array([OnebaseValue]), object([String: OnebaseValue]) } ``` Read it with `value?.string`, `.int`, `.double`, `.bool`, `.array`, `.object` and `value?["user", "name"]`; whole numbers arrive as `.int`, and it's `Codable`, `Equatable` and literal-expressible (`let v: OnebaseValue = ["count": 3]`). --- # Scheduling & crons ## Scheduling a function ```ts // Run in 5 minutes await ctx.scheduler.runAfter(5 * 60_000, internal.emails.sendReminder, { userId }); // Run at a specific time const jobId = await ctx.scheduler.runAt(new Date("2026-12-31T23:59:00Z"), internal.party.start, {}); // Cancel a job that hasn't run yet await ctx.scheduler.cancel(jobId); ``` - **From a mutation:** the job is created only if the mutation commits. Throw, and nothing is scheduled. - **Storage:** jobs are kept in the database, so they survive restarts. A job that was running when the server stopped runs again on the next start. - **Mutations run exactly once:** a scheduled mutation is marked done inside its own transaction. - **Actions and queries run at most once,** unless the server crashes mid-run (then the job is retried). - **Failures:** a failed job is logged and kept with its error for 7 days. - **Several instances:** each job is claimed by exactly one instance. A common pattern is to start long work from a mutation without making the user wait: ```ts export const requestSummary = mutation({ args: { id: v.id("posts") }, handler: async (ctx, { id }) => { await ctx.db.patch(id, { summary: "pending" }); await ctx.scheduler.runAfter(0, internal.ai.summarize, { id }); // an action }, }); ``` Scheduled functions all start when they're due and aren't retried. For many jobs at once, a limit on how many run together, retries or a callback when each finishes, use the [work queue](./work-queue.md). ## Cron jobs Declare recurring jobs in `onebase/crons.ts`: ```ts import { cronJobs } from "onebase/server"; import { internal } from "./_generated/api"; const crons = cronJobs(); crons.interval("sync feeds", { minutes: 15 }, internal.feeds.sync); crons.hourly("rollup", { minuteUTC: 5 }, internal.stats.rollup); crons.daily("prune", { hourUTC: 3, minuteUTC: 0 }, internal.messages.prune, { days: 30 }); crons.weekly("digest", { dayOfWeek: "monday", hourUTC: 8, minuteUTC: 0 }, internal.email.digest); crons.monthly("invoices", { day: 1, hourUTC: 6, minuteUTC: 0 }, internal.billing.invoice); crons.cron("weekday mornings", "30 9 * * mon-fri", internal.reports.daily); // standard 5-field cron export default crons; ``` - **Times:** all times are UTC. - **Missed runs:** the next run time is stored in the database. A restart doesn't repeat or skip runs, and a run missed while the server was down happens once on startup. - **No overlap:** a job that is still running when it comes due again is skipped. - **Several instances:** each run happens on exactly one of them. - **Changing a schedule:** edit `crons.ts`. Changed jobs get a new schedule, and removed jobs are deleted. `onebase doctor` reports crons that point at functions that don't exist, and the server refuses to start with one. --- # File storage Files are stored as bytes plus metadata. Reference them from documents with `v.id("_storage")`. ## Uploading from the browser **1.** Add a mutation that returns an upload URL: ```ts export const generateUploadUrl = mutation({ args: {}, handler: (ctx) => ctx.storage.generateUploadUrl(), }); export const savePhoto = mutation({ args: { image: v.id("_storage"), caption: v.string() }, handler: (ctx, args) => ctx.db.insert("photos", args), }); ``` **2.** On the client, POST the file to that URL, then save the returned id: ```ts const uploadUrl = await client.mutation(api.photos.generateUploadUrl); const res = await fetch(uploadUrl, { method: "POST", headers: { "Content-Type": file.type }, body: file }); const { storageId } = await res.json(); await client.mutation(api.photos.savePhoto, { image: storageId, caption }); ``` Upload URLs work once and expire after an hour. Uploads are streamed straight to storage and hashed on the way, so even large files never sit in memory. ## Serving files ```ts export const list = query({ args: {}, handler: async (ctx) => { const photos = await ctx.db.query("photos").collect(); return Promise.all(photos.map(async (p) => ({ ...p, url: await ctx.storage.getUrl(p.image) }))); }, }); ``` `getUrl` returns a stable URL, `/api/storage/`: - **On disk:** the server sends the file with long-lived caching headers. Images, audio, video, PDFs and plain text show in the browser; anything else (HTML, SVG, unknown types) downloads, so an uploaded file can't run script on your app's or the dashboard's origin. - **In S3:** the server redirects to a short-lived presigned URL, so the bytes come straight from S3. Set `--url https://api.example.com` so these URLs use your public address. ## API | | query | mutation | action | |---|:-:|:-:|:-:| | `ctx.storage.getUrl(id)` | ✓ | ✓ | ✓ | | `ctx.storage.getMetadata(id)` → `{ sha256, size, contentType, … }` | ✓ | ✓ | ✓ | | `ctx.storage.generateUploadUrl()` | | ✓ | ✓ | | `ctx.storage.delete(id)` | | ✓ | ✓ | | `ctx.storage.store(blob)` → id | | | ✓ | | `ctx.storage.get(id)` → `Blob` | | | ✓ | A `delete` inside a mutation is transactional. The bytes are removed only if the mutation commits. Actions can save generated content: ```ts const image = await fetch("https://example.com/cat.png").then((r) => r.blob()); const storageId = await ctx.storage.store(image); ``` ## Where files go | | | |---|---| | Default | `/files` (`./onebase_data/files`) | | S3 / R2 / MinIO | `--files s3://bucket/prefix` | S3 credentials come from environment variables: ```bash export S3_ACCESS_KEY_ID=... S3_SECRET_ACCESS_KEY=... export S3_ENDPOINT=https://.r2.cloudflarestorage.com # for R2, MinIO, etc. (omit for AWS) export S3_REGION=auto ``` ```bash onebase serve --files s3://my-bucket/uploads ``` The `AWS_*` equivalents work too, and so do query parameters on the URL (`s3://bucket/prefix?endpoint=…®ion=…`). --- # Auth There are three ways to know who's calling, and you can combine them: - **Built-in sign-in:** email and password, plus OAuth (GitHub, Google, any OpenID Connect or OAuth 2.0 provider). onebase stores the users and runs the sessions. - **External providers:** Clerk, Auth0, WorkOS or any OIDC issuer signs people in; onebase verifies their tokens. - **Your own tokens:** API keys or anything else, checked by an `authenticate(token, ctx)` function that can read the database. ## Built-in sign-in ### Set up Add the users table to your schema. `authTables` has the fields sign-in uses: ```ts // onebase/schema.ts import { authTables, defineSchema, defineTable } from "onebase/server"; export default defineSchema({ ...authTables, notes: defineTable({ owner: v.id("users"), text: v.string() }).index("by_owner", ["owner"]), }); ``` Then list the sign-in methods: ```ts // onebase/auth.config.ts import { github, google, password } from "onebase/auth"; export default { methods: [password(), github(), google()], }; ``` `github()` reads its credentials from `AUTH_GITHUB_ID` and `AUTH_GITHUB_SECRET`, and `google()` from `AUTH_GOOGLE_ID` and `AUTH_GOOGLE_SECRET`. You can also pass `{ clientId, clientSecret }`. ### Sign in from the client ```tsx import { useState } from "react"; import { Authenticated, Unauthenticated, useAuthActions } from "onebase/react"; function SignIn() { const { signIn } = useAuthActions(); const [flow, setFlow] = useState<"signIn" | "signUp">("signIn"); const [error, setError] = useState(""); return (
{ e.preventDefault(); const form = new FormData(e.currentTarget); signIn("password", { flow, email: form.get("email"), password: form.get("password") }).catch((err) => setError(err.message)); }} > {error &&

{error}

}
); } function App() { return ( <> ); } ``` | | | |---|---| | `signIn("password", { flow: "signUp", email, password, name? })` | creates the account and signs in | | `signIn("password", { flow: "signIn", email, password })` | signs in; rejects with "Invalid email or password." | | `signIn("github")` | goes to GitHub and back to this page (or `{ redirectTo }`), then finishes signing in | | `signIn("password", { flow: "reset", email })` | emails a code to choose a new password (see [below](#password-reset-and-email-verification)) | | `signOut()` | ends the session | | `useOnebaseAuth()` | `{ isLoading, isAuthenticated, error }` (also exported as `useConvexAuth`) | | ``, ``, `` | render their children only in that state | Signing up an address that already has an account never says so. With the right password it signs in (the same reply a new account gets); with a wrong one it fails exactly like sign-in. With `verifyEmail`, a code is emailed either way, and confirming it signs into the existing account. If that account was never confirmed (so anyone could have created it), confirming also sets the password to the one chosen in this sign-up and ends any earlier sessions. Without `verifyEmail`, the only remaining difference is that sign-up of an *unregistered* address succeeds — turn on `verifyEmail` if that distinction matters to you. Without React, the same methods are on the client: `client.signIn(...)`, `client.signOut()`, `client.authState` and `client.onAuthChange(listener)`. The session token is kept in `localStorage`, so a reload stays signed in. To keep it somewhere else, pass `new OnebaseClient(url, { authStorage })` with any object that has `getItem`, `setItem` and `removeItem`, or `null` to keep it in memory. `signIn` resolves `{ signingIn: true }` once signed in, and `{ signingIn: false }` when the next step happens elsewhere: an emailed code to enter, or an OAuth provider's page. ### Password reset and email verification Give `password()` a `sendCode` function that emails a one-time code, using any email API: ```ts // onebase/auth.config.ts import { password } from "onebase/auth"; export default { methods: [ password({ verifyEmail: true, // optional: new accounts confirm their email before they can sign in sendCode: async ({ email, code, purpose }) => { const subject = purpose === "reset" ? "Reset your password" : "Confirm your email"; const res = await fetch("https://api.resend.com/emails", { method: "POST", headers: { Authorization: `Bearer ${process.env.RESEND_API_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ from: "My App ", to: email, subject, text: `Your code is ${code}. It expires in 15 minutes.` }), }); if (!res.ok) throw new Error(`Resend returned ${res.status}`); }, }), ], }; ``` `sendCode` runs on the server. Its second argument has `runQuery`, `runMutation` and `runAction`, so it can also call an internal action that sends the email: `sendCode: (message, ctx) => ctx.runAction(internal.emails.sendCode, message)`. **Resetting a password** takes two calls: ```ts await signIn("password", { flow: "reset", email }); // emails a code await signIn("password", { flow: "reset-verification", email, code, newPassword }); // signs in ``` A reset ends every other session of that user, and marks the email as verified (they read the code in that inbox). **With `verifyEmail: true`,** signing up (and signing in to an account that hasn't confirmed yet) emails a code and resolves `{ signingIn: false }`. The user is signed in once they enter it: ```ts const { signingIn } = await signIn("password", { flow: "signUp", email, password }); if (!signingIn) showCodeInput(); // then: await signIn("password", { flow: "email-verification", email, code, password }); ``` Send the password again with the code. Anyone can sign an address up and get a code mailed to it, carrying the password they chose; asking for it again means only the person who chose the password can confirm it, so nobody else can pick the password the owner's confirmation sets. A wrong password leaves the code usable. Confirmed emails are stored in `users.emailVerificationTime`, which also lets an OAuth sign-in with the same verified email join that user. | | | |---|---| | Codes | 8 digits, valid for 15 minutes, work once; a new code replaces the previous one | | Wrong codes | 10 per email per network per hour, then that email is blocked from that network for the rest of the hour | | Wrong passwords | 10 per email per network per hour, then sign-in for that email is blocked from that network | | Codes sent | 5 per email per hour, so nobody can flood an inbox | | Unknown emails | a reset request answers `{ codeSent: true }` either way, so it doesn't reveal which emails have accounts | Wrong passwords and wrong codes are counted per email *and* per network (the client IP), so guessing at someone's address from elsewhere can't lock them out. Everyone signing in through one proxy or office NAT shares that network's count; pass `--trust-proxy` behind a reverse proxy so real client IPs are used. ### Use the user in functions ```ts import { getAuthUserId } from "onebase/server"; export const myNotes = query({ args: {}, handler: async (ctx) => { const userId = await getAuthUserId(ctx); // Id<"users"> | null if (!userId) return []; return await ctx.db.query("notes").withIndex("by_owner", (q) => q.eq("owner", userId)).collect(); }, }); ``` `ctx.auth.getUserIdentity()` works too. Its `subject` is the user's id, and it has `email`, `name` and `pictureUrl` from the user document. Users are ordinary documents, so you can read them, add fields and show them in the dashboard. To add your own fields, define `users` yourself instead of spreading `authTables`. Keep `name`, `email`, `image` and `emailVerificationTime`, and make new fields optional or fill them at sign-up with `password({ profile: (params) => ({ email: params.email, plan: "free" }) })`. The server checks at startup that the users table accepts new users. Don't let your own mutations change `email` or `emailVerificationTime`. An OAuth sign-in joins the user whose verified email matches, so a user who could set their email to someone else's would get that person's account. If users can change their email, clear `emailVerificationTime` in the same mutation and confirm the new address with a code. (`profile` can't set `emailVerificationTime`.) ### OAuth 1. Register an OAuth app with the provider. Its callback URL is `/api/auth/callback/`, e.g. `https://api.example.com/api/auth/callback/github`. 2. Start the server with that public URL: `--url https://api.example.com` (or `$ONEBASE_URL`). 3. If your frontend runs on another origin, allow it as a place to return to: list it in `--cors` or in `redirects`: ```ts export default { methods: [password(), github()], redirects: ["https://app.example.com"], // origins, or URL prefixes like "https://example.com/app/" }; ``` In dev mode, `localhost` is always allowed. Other providers: ```ts import { oauth, oidc } from "onebase/auth"; oidc({ id: "okta", issuer: "https://dev-123.okta.com", clientId, clientSecret }), oauth({ id: "discord", clientId, clientSecret, authorizationUrl: "https://discord.com/oauth2/authorize", tokenUrl: "https://discord.com/api/oauth2/token", scopes: ["identify", "email"], profile: async ({ access_token }) => { const me = await fetch("https://discord.com/api/users/@me", { headers: { Authorization: `Bearer ${access_token}` } }).then((r) => r.json()); return { id: me.id, email: me.email, emailVerified: me.verified, name: me.global_name, image: undefined }; }, }), ``` **Linking accounts:** an OAuth sign-in with a new account joins an existing user only when both the provider and that user have verified the email address. Otherwise it creates a new user. This stops someone from signing up with another person's email and later sharing their account. ### Sessions and security - **Sessions** last 30 days from their last use (`sessionDays` in `auth.config.ts`). Signing out ends the session at once, including for open WebSocket connections on every server instance. Open connections also re-check their identity about once a minute, so a session that expires (or whose user is deleted) signs its connection out without waiting for a reconnect. `POST /api/auth/signout` with `{ "everywhere": true }` ends all of a user's sessions. - **Passwords** are hashed with argon2id and must be at least 8 characters (`password({ minLength })`). After 10 failed attempts for an email from one network in an hour, sign-in for that email is blocked from that network for the rest of the hour. `--rate-limit` also applies to sign-in requests. - **Tokens and codes** are stored only as SHA-256 hashes. OAuth uses PKCE, and the one-time code the provider's redirect brings back only works with a verifier kept by the browser that started the sign-in. - **Stored separately:** password hashes, OAuth links, sessions and email codes are in system tables that functions and the dashboard can't read. ### HTTP API For clients other than the JS one: | Endpoint | | |---|---| | `POST /api/auth/signin` `{ provider: "password", params: { flow, email, password } }` | `{ token, userId }`, or `{ codeSent: true }` when a code was emailed | | `POST /api/auth/signin` `{ provider: "password", params: { flow: "reset", email } }` | `{ codeSent: true }`; then `flow: "reset-verification"` with `email, code, newPassword` | | `POST /api/auth/signin` `{ provider: "github", redirectTo, challenge }` | `{ redirect }`: send the browser there. `challenge` is the SHA-256 (base64url) of a random verifier you keep | | `POST /api/auth/signin` `{ code, verifier }` | `{ token, userId }`, for the `onebase_code` the browser comes back with | | `POST /api/auth/signout` with `Authorization: Bearer ` | `{ ok: true }` | | `GET /api/auth/methods` | the enabled methods, for building a sign-in page | Then send `Authorization: Bearer ` with requests, or `{ type: "auth", token }` over the WebSocket. ## External providers Create `onebase/auth.config.ts`. The provider format is the same as Convex: ```ts export default { providers: [ // Clerk, Auth0, WorkOS, Google… any OIDC issuer { domain: "https://your-app.clerk.accounts.dev", applicationID: "onebase" }, // An issuer without OIDC discovery: point at its JWKS { type: "customJwt", issuer: "https://auth.example.com", jwks: "https://auth.example.com/.well-known/jwks.json", algorithm: "RS256" }, ], }; ``` - **`domain`** is the token's issuer (`iss`). Keys are fetched from `/.well-known/openid-configuration`, cached, and refetched when the provider rotates them. - **`applicationID`**, if set, must appear in the token's audience (`aud`). In Clerk, create a JWT template with that name. In Auth0, set it as the API audience. - **Algorithms:** RS256/384/512, PS256, ES256/384 and EdDSA are supported, all verified with WebCrypto. ### Send the token ```ts client.setAuth(async () => await clerk.session?.getToken({ template: "onebase" })); ``` Queries, mutations and subscriptions started while the token is being fetched wait for it, so they never run as a signed-out user by accident. Over HTTP, send `Authorization: Bearer `. ### Use the identity ```ts export const me = query({ args: {}, handler: async (ctx) => { const identity = await ctx.auth.getUserIdentity(); if (!identity) return null; // not signed in return await ctx.db .query("users") .withIndex("by_token", (q) => q.eq("tokenIdentifier", identity.tokenIdentifier)) .unique(); }, }); ``` The identity has: - `subject`: the user id at the provider. - `issuer`. - `tokenIdentifier`: `issuer|subject`, unique across providers. - The standard claims, in camelCase: `email`, `name`, `givenName`, `pictureUrl`, `emailVerified`, … - Any custom claims, unchanged. Tokens are checked for expiry (with 60 seconds of leeway) and audience. When a token is rejected, the reason is clear, e.g. *"Auth token expired at …"* or *"audience … doesn't include applicationID"*. ## Custom tokens For API keys, tokens from your own system, or anything else, add an `authenticate` function. It's used for every token that isn't a built-in session or from a configured provider. Its `ctx.db` reads the database: ```ts export default { methods: [password()], authenticate: async (token, ctx) => { const hash = new Bun.CryptoHasher("sha256").update(token).digest("hex"); // store keys hashed const key = await ctx.db.query("apiKeys").withIndex("by_hash", (q) => q.eq("hash", hash)).unique(); return key ? { subject: key.owner, tokenIdentifier: `apikey|${key._id}` } : null; }, }; ``` Return `null` to treat the caller as signed out. Each `ctx.db` read runs on its own snapshot, so nothing is held open while `authenticate` waits on other work. --- # HTTP actions Define HTTP endpoints in `onebase/http.ts`. They're served on the same port as everything else. ```ts import { httpRouter } from "onebase/server"; import { internal } from "./_generated/api"; import { httpAction } from "./_generated/server"; const http = httpRouter(); http.route({ path: "/webhooks/stripe", method: "POST", handler: httpAction(async (ctx, request) => { const event = await request.json(); await ctx.runMutation(internal.billing.handleEvent, { event }); return new Response(null, { status: 200 }); }), }); // Every path under /files/ ("pathPrefix" matches the longest prefix) http.route({ pathPrefix: "/files/", method: "GET", handler: httpAction(async (ctx, request) => { const id = new URL(request.url).pathname.slice("/files/".length); const url = await ctx.storage.getUrl(id); return url ? Response.redirect(url) : new Response("Not found", { status: 404 }); }), }); export default http; ``` - **Handlers** get the same `ctx` as actions (`runQuery`, `runMutation`, `scheduler`, `storage`, `auth`) and a standard `Request`. They return a standard `Response`. - **Auth:** an `Authorization: Bearer` header is verified like any other token (see [Auth](./auth.md)). - **Reserved paths:** `/api/*` and `/_onebase/*` belong to onebase. Static files from `public/` are matched after your routes. --- # Components A component is a package of backend code with its own tables and functions: a rate limiter, a counter, a work queue. You mount it in `onebase/components.ts` and call it from your functions as `ctx.components..(args)`. Its data lives in the same database as yours, in tables only it can read or write. Convex has components too. onebase's are used differently: | | Convex | onebase | |---|---|---| | Install | `convex.config.ts` with `defineApp().use(...)`, then `npx convex dev` to generate code | `defineComponents({...})` in `components.ts`; types come straight from the package, with no extra codegen | | Call | `ctx.runMutation(components.limiter.lib.limit, args)`, usually through a wrapper class | `ctx.components.limiter.limit(args)`, typed from what you mounted | | Caller's identity | Never visible to components | Visible when you mount with `auth: true` | | Configuration | Passed on every call or to a wrapper class | Given once, when mounting | | Following a table | You add triggers to every write yourself | Table hooks: the component gets every change, in the same transaction | | Calling from clients | Re-export each function from your own module | `expose: ["fn"]` when mounting | ## Mount a component ```ts // onebase/components.ts import { defineComponents } from "onebase/components"; import { rateLimiter, MINUTE } from "onebase/components/rate-limiter"; export default defineComponents({ limiter: rateLimiter({ rates: { sendMessage: { kind: "token bucket", rate: 10, period: MINUTE } }, }), }); ``` The key (`limiter`) is the mount name: letters, digits and `_`, starting with a letter. On the next start, or `onebase dev` restart, onebase creates the component's tables and indexes, and `_generated/server.ts` types `ctx.components`. ## Call it ```ts // onebase/messages.ts export const send = mutation({ args: { body: v.string() }, handler: async (ctx, { body }) => { const userId = await getAuthUserId(ctx); await ctx.components.limiter.limit({ name: "sendMessage", key: userId!, throws: true }); await ctx.db.insert("messages", { body, author: userId! }); }, }); ``` Calls follow the usual rules for the kind of function making them: - **Queries** can call a component's queries. What the component reads is tracked like your own reads, so a live query that calls one re-runs when that component's data changes, and only then. - **Mutations** can call its queries and mutations, in the same transaction. If the component's mutation throws and you catch the error, only the component's writes are rolled back; yours stay. If you don't catch it, the whole mutation is rolled back, as usual. - **Actions** can call anything; each call is its own transaction, as with `ctx.runMutation`. Arguments and return values are checked against the component's validators. Errors name the function, such as `components/limiter:limit`. ## Isolation Each mount has its own tables. They are stored as `.` (for example `limiter.rateLimits`) next to your tables, so backups, dashboards and database switches cover them too. - Your functions can't read or write a component's tables, and a component can't read or write yours: `ctx.db.get()` returns null for the other side's ids, and `patch`, `replace` and `delete` refuse them. - Ids cross the boundary as plain strings. Pass a component your document ids; it stores them as `v.string()`. - Components can't call your functions or each other's, except through [function handles](#function-handles) you give them. - Clients can't call component functions directly unless you [expose](#expose-functions-to-clients) them. Otherwise write your own query or mutation that calls the component, checking whatever your app needs first. Isolation keeps components' data apart. It isn't a sandbox: components are npm packages running in your process, so install ones you trust. ## Table hooks A component can follow one of your tables. The [aggregate](./aggregate.md) does: mounted with `table: "scores"`, it gets every insert, patch, replace and delete on `scores`, from any mutation, dashboard edits and sign-ups included. - **Same transaction:** the component gets the changes in the transaction that wrote them, before the transaction commits. If it fails, the write fails with it. - **Up to date:** if your mutation calls the component after writing, the component already has those writes. - **One change per document:** several writes to one document in a transaction arrive as one change. A document inserted and deleted in the same transaction never reaches the component. - **Existing documents:** documents written before the component was mounted are fed to it in the background, 200 per transaction. The component reports when it has caught up (the aggregate's `status()`). ## Expose functions to clients To let browsers call some of a component's functions directly, list them when mounting: ```ts presence: presence({ auth: true, expose: ["heartbeat", "list"] }), ``` Clients then call `api.components.presence.heartbeat` like any of your functions: `useQuery(api.components.presence.list, { room })`. `_generated/api.ts` has `api.components` for exposed functions only, and `internal.components` for all of a component's public functions, which you can schedule or run from crons. Nested functions are exposed by their dotted path: `expose: ["lib.list"]`. Expose only what's safe for anyone to call with any arguments. A rate limiter's `reset`, for one, isn't. ## Function handles `createFunctionHandle(ref)` (from `onebase/server`) turns a reference to one of your functions into a string the component can keep and call later: ```ts await ctx.components.queue.enqueue({ fn: await createFunctionHandle(internal.emails.send), args: { to } }); ``` When you pass a function reference itself in a component call's arguments (`{ fn: internal.emails.send }`), onebase converts it to a handle for you. The component calls the handle with `ctx.runMutation(handle, args)` (or `runQuery`, `runAction`), or schedules it with `ctx.scheduler.runAfter`. Handles are signed with a key kept in the database. One can't be made up by hand, so a client can't pass an exposed component a handle to a function you never handed out. A handle keeps working after restarts and on every instance. Clients can't call handles; only server code can. ## Crons and HTTP routes Components can declare cron jobs and HTTP routes; they run for every mount. - **Cron jobs** are named `components//` and show up with yours in the dashboard. - **HTTP routes** are served under the mount's prefix: `/` by default, so a component mounted as `stripe` with a `/webhook` route answers at `/stripe/webhook`. Change it with `httpPrefix: "/payments"`, or turn the routes off with `httpPrefix: false`. Routes in your own `http.ts` take precedence, so you can always override one. - **Identity:** route handlers see the caller's identity only when the component is mounted with `auth: true`. ## The caller's identity By default a component's `ctx.auth.getUserIdentity()` returns null. The app checks who the caller is and passes the component what it needs, such as a user id as a `key`. To let a component see the identity itself, mount it with `auth: true`: ```ts presence: presence({ auth: true }), ``` ## The same component twice Mount it under two names and each copy gets its own tables and configuration: ```ts export default defineComponents({ apiLimiter: rateLimiter({ rates: { call: { kind: "fixed window", rate: 1000, period: HOUR } } }), loginLimiter: rateLimiter({ rates: { attempt: { kind: "token bucket", rate: 5, period: MINUTE } } }), }); ``` ## Removing a component Delete it from `components.ts`. Its indexes are dropped and its documents are kept: startup warns which tables still hold data. Mount it again under the same name to use them, or delete them from the dashboard's Data page, where the component's tables are listed under its name. Renaming a mount is like removing one and adding another: the new name starts with empty tables. ## Components that ship with onebase | Component | Import | | |---|---|---| | [Rate limiter](./rate-limiter.md) | `onebase/components/rate-limiter` | Token bucket and fixed window limits per key, stored in the database | | [Aggregate](./aggregate.md) | `onebase/components/aggregate` | Counts, sums, ranks and the n-th item over a table in O(log n), kept in step with it | | [Sharded counter](./sharded-counter.md) | `onebase/components/sharded-counter` | Counters that many writers can bump at once | | [Work queue](./work-queue.md) | `onebase/components/work-queue` | Background actions and mutations with limited parallelism, retries and completion callbacks | To write your own, see [Writing components](./components-authoring.md). ## Notes - `components.ts` is a reserved file name, and so is a `components/` folder of functions while it exists. - `components.ts` may import types from `./_generated/dataModel` (to type a configuration callback, say), but not from `./_generated/server` or `./_generated/api`: those are typed from `components.ts`, so the import would be circular. `onebase doctor` reports it. - On MySQL, which can't index JSON values, a component's queries read all of that component's documents. That's fine for a few thousand; for more, use SQLite or Postgres. --- # Rate limiter The rate limiter component limits how often something happens, per key: messages per user, sign-in attempts per email, API calls per account. Limits are stored in the database, so they hold across restarts and across several instances. They're checked in your mutation's transaction, so if the mutation fails afterwards, the token is given back. This isn't the server's `--rate-limit` flag. That one caps requests per IP before any function runs, in memory, per instance. Use the flag to protect the server, and this component for rules about your app. ## Set it up ```ts // onebase/components.ts import { defineComponents } from "onebase/components"; import { rateLimiter, MINUTE, HOUR } from "onebase/components/rate-limiter"; export default defineComponents({ limiter: rateLimiter({ rates: { // Bursts of up to 10 messages, then one every 6 seconds on average. sendMessage: { kind: "token bucket", rate: 10, period: MINUTE }, // 3 attempts per hour, counted in fixed hourly windows. signUp: { kind: "fixed window", rate: 3, period: HOUR }, }, }), }); ``` ## Use it ```ts export const send = mutation({ args: { body: v.string() }, handler: async (ctx, { body }) => { const userId = await getAuthUserId(ctx); if (!userId) throw new ConvexError("Sign in first"); const { ok, retryAfter } = await ctx.components.limiter.limit({ name: "sendMessage", key: userId }); if (!ok) throw new ConvexError(`Slow down: try again in ${Math.ceil(retryAfter / 1000)}s.`); await ctx.db.insert("messages", { body, author: userId }); }, }); ``` Or let it throw, and the whole mutation is rolled back: ```ts await ctx.components.limiter.limit({ name: "sendMessage", key: userId, throws: true }); ``` It throws a `ConvexError` whose `data` is `{ kind: "RateLimited", name, retryAfter }`. Clients get that `data` with the error, and `isRateLimitError(err)` (from `onebase/components/rate-limiter`) recognizes it on the server. ## Functions | | | |---|---| | `limit({ name, key?, count?, reserve?, throws? })` | Mutation. Takes `count` tokens (default 1). Returns `{ ok: true }`, or `{ ok: false, retryAfter }` with the ms to wait. | | `check({ name, key?, count? })` | Query. Whether `limit()` would be allowed now, without taking anything. Useful to disable a button. | | `reset({ name, key? })` | Mutation. Forgets what was used: the full capacity is available again. | | `getValue({ name, key? })` | Query. `{ value, ts }`: tokens available now. | `key` makes a separate limit per user, IP, email or whatever you pass; leave it out for one global limit. Every function also takes `config` instead of a configured name, for limits you decide at runtime: `limit({ name: "export", key: orgId, config: { kind: "fixed window", rate: plan.exportsPerDay, period: DAY } })`. ## Configuration | | | |---|---| | `kind` | `"token bucket"`: tokens refill continuously, so after a burst the next one comes back in `period / rate`. `"fixed window"`: `rate` tokens are added at the start of each period. | | `rate`, `period` | Tokens added per period. Periods are in ms; use `SECOND`, `MINUTE`, `HOUR` and `DAY`. | | `capacity` | The most tokens that can be saved up (default `rate`). A larger capacity allows larger bursts. | | `shards` | Splits the limit over this many documents with an equal share each, so many writes to one busy limit don't conflict. Only useful with several instances on Postgres or MySQL; one instance runs mutations one at a time anyway. | | `start` | Fixed window only: when windows start, in ms since the epoch (default 0, so windows line up with the clock). | | `maxReserved` | How far `reserve: true` may go into debt (default: no limit). | ## Reserving ahead With `reserve: true`, a call that would be refused is allowed anyway and returns `{ ok: true, retryAfter }`: the time when its tokens will have been earned. Use it to queue work rather than reject it, say by scheduling the work to run after `retryAfter`. ```ts const { retryAfter } = await ctx.components.limiter.limit({ name: "llmRequests", reserve: true }); await ctx.scheduler.runAfter(retryAfter ?? 0, internal.ai.generate, { prompt }); ``` --- # Aggregate Counting documents with `.collect().length` reads every one of them. The aggregate component keeps a counted tree of a table's documents, sorted by a key you choose, so these take a few document reads however large the table is: - **how many:** "players", "messages in this channel", "scores between 100 and 200" - **sums:** total points, revenue this month - **ranks:** "you're #42" - **the n-th item:** the leaderboard page 3, a random document, the median It follows the table through a [table hook](./components.md#table-hooks): every insert, patch, replace and delete updates it in the same transaction, including dashboard edits and sign-ups. There are no triggers to wire up and nothing to forget. ## Set it up ```ts // onebase/components.ts import { defineComponents } from "onebase/components"; import { aggregate } from "onebase/components/aggregate"; import type { Doc } from "./_generated/dataModel"; export default defineComponents({ leaderboard: aggregate({ table: "scores", sortKey: (d: Doc<"scores">) => d.points, sumValue: (d) => d.points, }), }); ``` | | | |---|---| | `table` | The app table to follow. | | `sortKey(doc)` | What to order by: a number, string, boolean, null or an array of them (to sort by several fields: `[d.level, d.points]`). Documents with equal keys are ordered by id. | | `sumValue(doc)` | Optional: the number `sum()` adds up (default 0). | | `namespace(doc)` | Optional: keeps a separate tree per value, such as per channel or per game. Queries then pass `namespace`. Separate trees also mean writes to different namespaces don't touch the same documents. | | `filter(doc)` | Optional: only documents it returns true for are counted. | | `maxNodeSize` | Items per tree node (default 32). | When the component is first mounted, documents already in the table are added in the background, 200 per transaction, while the app keeps running. Until that's done, `status()` returns `{ ready: false, state: "backfilling" }` and counts cover only part of the table. Changing `sortKey`, `sumValue`, `namespace`, `filter` or `maxNodeSize` rebuilds it the same way. ## Query it From queries (live like any query), mutations or actions: ```ts export const myRank = query({ args: { points: v.number() }, handler: async (ctx, { points }) => ({ rank: (await ctx.components.leaderboard.indexOf({ key: points, order: "desc" })) + 1, of: await ctx.components.leaderboard.count(), }), }); export const page = query({ args: { page: v.number() }, handler: async (ctx, { page }) => { const top = []; for (let i = page * 10; i < page * 10 + 10; i++) { const item = await ctx.components.leaderboard.at({ index: i, order: "desc" }); if (item) top.push(await ctx.db.get(item.id as Id<"scores">)); } return top; }, }); ``` | | | |---|---| | `count({ namespace?, bounds? })` | How many items. | | `sum({ namespace?, bounds? })` | The total of `sumValue`. | | `indexOf({ namespace?, key, id?, order? })` | How many items come before `key` (with `order: "desc"`, after it): a 0-based rank. With `id`, the position of that exact document. | | `at({ namespace?, index, order? })` | The item at a position: `{ key, id, sumValue }`, or null. | | `min({ namespace? })`, `max({ namespace? })` | The first and last items. | | `paginate({ namespace?, bounds?, cursor?, numItems, order? })` | Items in order, a page at a time: `{ page, isDone, continueCursor }`. | | `status()` | `{ ready, state }`: whether it has caught up with the table. | `bounds` narrows by key: `{ lower: { key: 100, inclusive: true }, upper: { key: 200, inclusive: false } }`. Either side can be left out. Items hold the document's id. Load the document with `ctx.db.get(item.id)`. ## Without a table Leave out `table` to add items yourself, for data that isn't one row per document: ```ts pageViews: aggregate(), // key: a timestamp; namespace: the page ``` ```ts await ctx.components.pageViews.insert({ namespace: path, key: Date.now(), id: crypto.randomUUID(), sumValue: 1 }); const today = await ctx.components.pageViews.count({ namespace: path, bounds: { lower: { key: startOfDay, inclusive: true } } }); ``` `insert`, `delete({ key, id })`, `replace({ from, to })` and `clear({ namespace? })` are mutations, so they run in your transaction. An aggregate that follows a table refuses them. ## Costs - **Writes:** each write to the table updates a few tree documents, about 0.1 ms per write on SQLite. Every write in a namespace updates that namespace's root, so writes to one namespace don't run in parallel, even across instances. Use namespaces to spread busy tables. - **Reads:** a count without bounds reads one document; ranks and positions read one document per tree level (3 to 4 for a million items). About 30 µs on SQLite over 10,000 rows. - **Live queries:** every write to the table changes the root of its namespace, so every live query over that namespace re-runs. That's what makes counts live; keep namespaces narrow if a table is written to constantly. --- # Sharded counter A counter kept in one document is fine until many requests bump it at once: page views, likes on a popular post, a global "messages sent" total. Every write then changes the same document, and with several instances on Postgres or MySQL those writes conflict and retry. The sharded counter spreads each counter over several documents (shards). Each write touches one shard at random, and reading the counter adds them up. With a single instance this matters less, because onebase runs one process's mutations one at a time anyway. It still keeps live queries that read one shard from re-running on every write. ## Set it up ```ts // onebase/components.ts import { defineComponents } from "onebase/components"; import { shardedCounter } from "onebase/components/sharded-counter"; export default defineComponents({ counter: shardedCounter({ shards: 16, perName: { pageViews: 64 } }), }); ``` Counters don't need declaring: any name works. `shards` (default 16) sets the number of documents per counter, and `perName` changes it for some counters. ## Use it ```ts export const view = mutation({ args: { page: v.string() }, handler: async (ctx, { page }) => { await ctx.components.counter.inc({ name: `views:${page}` }); }, }); export const views = query({ args: { page: v.string() }, handler: (ctx, { page }) => ctx.components.counter.count({ name: `views:${page}` }), }); ``` | | | |---|---| | `inc({ name })`, `dec({ name })` | Mutations: add or subtract 1. | | `add({ name, count?, shard? })` | Mutation: adds `count` (default 1, negative to subtract). `shard` picks the shard, say to keep one user's writes on one shard. | | `count({ name })` | Query: the exact value, reading every shard. | | `estimateCount({ name, readShards? })` | Query: reads `readShards` random shards (default 1) and scales up. Fewer reads, and a live query over it re-runs only when those shards change. | | `reset({ name })` | Mutation: back to 0. | To show a count on a page many people watch, `estimateCount` keeps updates down; use `count` where the exact number matters. --- # Work queue `ctx.scheduler.runAfter(0, ...)` starts work right away, all of it at once. The work queue component runs it at a pace you set instead: - **Parallelism:** at most `maxParallelism` items run at the same time, so a burst of 10,000 emails doesn't open 10,000 connections or hit an API's rate limit. - **Retries:** failed items are retried with exponential backoff, up to `maxAttempts`. - **Completion callback:** each item can name an `onComplete` mutation that runs when it succeeds, fails for good or is canceled, with your `context`. - **Status:** `status(id)` and `list(state)` show where items stand, live like any query. Items are documents, so the queue survives restarts and is shared by every instance. ## Set it up ```ts // onebase/components.ts import { defineComponents } from "onebase/components"; import { workQueue } from "onebase/components/work-queue"; export default defineComponents({ emails: workQueue({ maxParallelism: 5, retry: { maxAttempts: 4 } }), }); ``` | | | |---|---| | `maxParallelism` | Items running at once (default 10). | | `retry` | `{ maxAttempts, initialBackoffMs, base }`. `maxAttempts` counts every run, the first included (default 1: no retries). The first retry waits `initialBackoffMs` (default 250), and each later one `base` times longer (default 2), ±50% so retries don't line up. | | `timeoutMs` | A running item that hasn't finished after this long counts as a failed attempt (default 15 minutes). This catches work lost when an instance stops mid-action. | | `retentionMs` | How long finished items are kept for `status()` and `list()` (default 7 days). | ## Queue work ```ts export const signUp = mutation({ args: { email: v.string() }, handler: async (ctx, { email }) => { const userId = await ctx.db.insert("users", { email }); await ctx.components.emails.enqueueAction({ fn: internal.emails.sendWelcome, args: { to: email }, onComplete: internal.emails.welcomeSent, context: { userId }, }); }, }); export const welcomeSent = internalMutation({ args: onCompleteArgs(v.object({ userId: v.id("users") })), // from "onebase/components/work-queue" handler: async (ctx, { context, result }) => { if (result.kind === "success") await ctx.db.patch(context.userId, { welcomed: true }); if (result.kind === "failed") console.error(`Welcome email failed: ${result.error}`); }, }); ``` The item is queued in your mutation's transaction, so if the mutation fails, nothing is queued. `fn` and `onComplete` are function references (or [handles](./components.md#function-handles)), and `args` is checked against `fn`'s arguments at compile time. | | | |---|---| | `enqueueAction({ fn, args, ... })` | Queues an action. Returns the item's id. | | `enqueueMutation({ fn, args, ... })` | Queues a mutation. It runs in its own transaction, together with recording its result, so each attempt happens exactly once. | | Options | `runAt` (a timestamp) or `delayMs` to start later; `retry` to override the queue's; `onComplete` and `context`. | | `cancel({ id })` | Stops an item that hasn't finished. An action already running can't be interrupted, but its result is ignored. `onComplete` gets `{ kind: "canceled" }`. | | `status({ id })` | `{ id, state, attempts, runAt, error, result }`, or null once the item has been forgotten. `state` is `pending`, `running`, `succeeded`, `failed` or `canceled`. | | `list({ state, paginationOpts })` | Items in one state, oldest first. | `onComplete` receives `{ id, context, result }`. Its args are `onCompleteArgs(contextValidator)`, which checks and types `context`, or `onCompleteValidator` for any context. `result` is `{ kind: "success", returnValue }`, `{ kind: "failed", error }` (after the last attempt) or `{ kind: "canceled" }`. If `onComplete` throws, its writes are rolled back and the error is logged; the item's own result stands. ## How it runs - **Starting items:** a `pump` mutation starts due items while slots are free, by scheduling them with the [scheduler](./scheduling.md). It runs again whenever an item finishes and when a delayed item becomes due, and only one run of it is scheduled at a time. - **Recording results:** an action's result is recorded in a mutation after it returns. A mutation's result is recorded in the same transaction it runs in. - **Recovery:** a `sweep` cron runs every minute. It fails attempts that went past `timeoutMs`, so they're retried if attempts remain, and deletes finished items older than `retentionMs`. If an attempt that timed out finishes late, its result is ignored. - **Retrying actions:** an action that fails partway through, after sending an email for instance, is retried from the start. Make actions that retry idempotent, or leave `maxAttempts` at 1. --- # Writing components A component is a schema, some functions and, optionally, a configuration check. You write it with the same API the components that ship with onebase use (the [rate limiter](./rate-limiter.md) is a short example to read). ```ts // counter/index.ts import { componentServer, defineComponent, splitMountOptions, type ComponentFn, type Mount, type MountOptions } from "onebase/components"; import { defineSchema, defineTable, type DataModelFromSchema } from "onebase/server"; import { v } from "onebase/values"; // 1. Tables, only the component can use them. const schema = defineSchema({ counters: defineTable({ name: v.string(), value: v.number() }).index("by_name", ["name"]), }); // 2. Builders typed with those tables and the configuration. type Config = { start: number }; const { query, mutation } = componentServer, Config>(); const find = (ctx: { db: any }, name: string) => ctx.db.query("counters").withIndex("by_name", (q: any) => q.eq("name", name)).unique(); const add = mutation({ args: { name: v.string(), by: v.optional(v.number()) }, handler: async (ctx, { name, by = 1 }) => { const row = await find(ctx, name); const value = (row?.value ?? ctx.config.start) + by; if (row) await ctx.db.patch(row._id, { value }); else await ctx.db.insert("counters", { name, value }); return value; }, }); const get = query({ args: { name: v.string() }, handler: async (ctx, { name }) => (await find(ctx, name))?.value ?? ctx.config.start, }); // 3. The component. export const definition = defineComponent({ name: "counter", schema, functions: { add, get }, checkConfig: (config: Config) => { if (typeof config?.start !== "number") throw new Error("counter({ start }) needs a number."); }, }); // 4. A factory apps call in components.ts. export function counter(opts: Config & MountOptions) { const { config, options } = splitMountOptions(opts); return definition.mount(config, options); } ``` The app mounts it with `counter: counter({ start: 0 })` and calls `ctx.components.counter.add({ name: "visits" })`. ## What a component's functions get | | | |---|---| | `ctx.db` | The component's own tables, by their names in its schema (`"counters"`, not `"counter.counters"`). Documents are validated against its schema, always. | | `ctx.config` | The configuration the app mounted this copy with. It can hold functions, such as a callback that picks a field. | | `ctx.component.mount` | The name this copy is mounted under. | | `ctx.auth` | The caller's identity when mounted with `auth: true`, otherwise always null. | | `ctx.runQuery(fn, args)` / `ctx.runMutation(fn, args)` | Calls another of the component's functions. Pass the function itself (`ctx.runMutation(add, {...})`), since components don't have generated references. Also takes a [function handle](./components.md#function-handles) the app passed in, which can be one of the app's functions. | | `ctx.scheduler.runAfter(ms, fn, args)` | Schedules one of the component's functions or a handle, the same way. `ctx.scheduler.cancel(id)` cancels only the component's own jobs. | | `ctx.vectorSearch(...)` | In actions, over the component's tables. | | `ctx.hooks.status(table?)` | Whether a table hook has caught up with its table: `{ table, state, ready }`. | `ctx.storage` isn't available to components. ## Following a table To keep something derived from an app table, give the component `hooks`. Each hook names the table and an internal mutation that receives the changes: ```ts import { tableChangesValidator, type TableChange } from "onebase/components"; const onChange = internalMutation({ args: tableChangesValidator, handler: async (ctx, { changes }) => { for (const change of changes as TableChange[]) { // change.op is "insert", "update" or "delete"; before and after are the documents (null when absent). } return null; }, }); const reset = internalMutation({ args: {}, handler: async (ctx) => { const batch = await ctx.db.query("derived").take(200); for (const doc of batch) await ctx.db.delete(doc._id); return { done: batch.length === 0 }; }, }); export const definition = defineComponent({ name: "follower", schema, functions: { onChange, reset /* , ...the public API */ }, hooks: (config: Config) => [{ table: config.table, onChange, reset, fingerprint: String(config.pick) }], }); ``` How hooks run: - **Timing:** `onChange` runs in the writing transaction, before the component is next called and before commit, with one change per document. If it throws, the writing mutation fails. - **Existing documents:** documents already in the table arrive as inserts, 200 per transaction, after `reset` has returned `{ done: true }`. `reset` should delete what the component derived earlier, a batch per call. - **Rebuilds:** a different `fingerprint` (typically derived from the configuration) resets and rebuilds. - **Progress:** `ctx.hooks.status(table)` returns `{ state, ready }`, so the component can report whether it has caught up. - **Validation:** the hooked table must be in the app's `schema.ts`. `onChange` and `reset` must be listed in the component's `functions`. ## Cron jobs and HTTP routes ```ts const { internalMutation, httpAction } = componentServer(); const cleanUp = internalMutation({ args: {}, handler: async (ctx) => { /* ... */ } }); export const definition = defineComponent({ name: "sessions", schema, functions: { cleanUp /* , ... */ }, crons: (config) => [{ name: "cleanUp", schedule: { minutes: 10 }, fn: cleanUp }], http: (config) => [ { path: "/webhook", method: "POST", handler: httpAction(async (ctx, request) => new Response("ok")) }, ], }); ``` - **Cron schedules:** an interval (`{ seconds, minutes, hours }`, at least 1 second) or a cron expression (`"0 3 * * *"`, UTC). The job must run one of the component's own functions. - **HTTP routes:** take `path` or `pathPrefix`, relative to the mount's `httpPrefix`. Their `ctx` is the component's action ctx, so they reach the component's tables through `ctx.runQuery` and `ctx.runMutation`. ## Public and internal functions Functions built with `query`, `mutation` and `action` are the component's API: the app can call them. Ones built with `internalQuery`, `internalMutation` and `internalAction` can only be called by the component itself, for example from its scheduler. Group functions with nesting: `functions: { lib: { add, get } }` is called as `ctx.components.counter.lib.add()`. ## Typing the API The app's types come from the functions' validators, with no codegen. To make them more precise than the validators can, such as accepting only the limit names the app configured, give the mount a hand-written API type, as the rate limiter does: ```ts export type CounterApi = { add: ComponentFn<"mutation", (args: { name: Name; by?: number }) => Promise>; get: ComponentFn<"query", (args: { name: Name }) => Promise>; }; export function counter(opts: { names: N[] } & Config & MountOptions): Mount> { const { config, options } = splitMountOptions(opts); return definition.mount(config, options) as Mount>; } ``` ## Publishing A component is an ordinary npm package that imports `onebase/components`, `onebase/server` and `onebase/values`. List `onebase` as a peer dependency: the server provides those modules itself, also to code pushed with `onebase deploy` and inside compiled binaries. --- # Deployment A onebase server is one executable. There are three ways to get your code onto it: - **`onebase deploy`:** push from your machine to a running server, like `npx convex deploy`. There's no restart, the new schema is checked against the data first, and you can roll back. - **Binary + functions folder:** copy your `onebase/` folder next to the binary and restart, like PocketBase with `pb_hooks`. - **Everything in one file:** compile functions, frontend and runtime into a single executable. ## onebase deploy Set up the server once. It needs only the binary and its settings, not your functions: ```bash sudo install onebase-linux-x64 /usr/local/bin/onebase ``` ```bash # /srv/app/.env ONEBASE_URL=https://api.example.com ONEBASE_ADMIN_KEY=a-long-random-secret RESEND_API_KEY=re_... ``` ```bash cd /srv/app && onebase serve ``` Run it with [systemd](#systemd) or [Docker](#docker) to keep it up. Then, on your machine, put the server's address and its admin key in `.env.local`, which git ignores: ```bash # .env.local ONEBASE_DEPLOY_URL=https://api.example.com ONEBASE_DEPLOY_KEY=a-long-random-secret ``` Ship a new feature with one command: ```bash onebase deploy ``` ``` Deploying 5 source file(s) (6.0 KB) and 1 static file(s) from public/ (7.7 KB) to https://api.example.com… ✓ Deployed 8awwre3jhj9nc8w182j7 to https://api.example.com in 310ms: 10 function(s), indexes +1 -0, 1 static file(s). ``` What happens: 1. **On your machine:** `codegen` and `doctor` run, and doctor errors stop the deploy. Your functions and the npm packages they import are bundled into one module. The `public/` folder is included (pick another folder with `--public ./dist`). 2. **On the server:** the new code is loaded, and auth config and crons are checked. When `schema.ts` changed, every document is checked against the new schema, in the same transaction that updates the indexes, like a Convex push. If any check fails, nothing changes and the old code keeps running. 3. **The swap:** new calls use the new code, and calls already running finish with the old code. The replaced deployment's module is dropped from the module cache, so its memory is freed once those calls finish. Connected clients stay connected, and their live queries re-run on the new code. Two more forms: ```bash onebase deploy --dry-run # check the code against the server's data, change nothing ``` A dry run never runs the pushed code on the server: the CLI reads the schema from the project locally and ships it as data, and the server checks that against every document (and answers with the function count). Bundles whose top level never settles (an awaited promise that never resolves) are refused after 30 seconds, on a dry run and on a real deploy alike. ```bash onebase deploy --rollback # go back to the deployment that was live before this one (the last 10 are kept) ``` Things to know: - **Where it's kept:** in the database. A restart keeps running the deployment, backups include it, and with [several instances](./databases.md) on Postgres or Turso they all switch to it together. The other instances switch a moment after the deploy commits (under a second), and until then they check writes against the old schema. So with several instances, a write that lands on another instance in that moment isn't checked against a validator the deploy tightened. - **It replaces the local code:** a deployment runs instead of the server's `onebase/` folder or the code built into the binary. Start with `--no-deploy` (or `ONEBASE_DEPLOY=off`; `0`, `false` and `no` work too) to run the local code and refuse pushes. `onebase dev` servers always refuse them. - **Secrets stay on the server:** a deploy ships code, not environment variables. Functions read `process.env` on the server, so put API keys in the server's `.env`. - **The key:** `ONEBASE_DEPLOY_KEY` is the server's [admin key](./dashboard.md): `ONEBASE_ADMIN_KEY` there, or the contents of its `onebase_data/admin.key`. Anyone with it can run code on the server. Deploy over `https://`. - **The admin API:** deploys go to `/_admin/api/deploy`, so `--no-dashboard` turns them off too. If you block `/_admin` at the proxy, deploy through an SSH tunnel (`--url http://localhost:8090`) or allow that path from your CI's addresses. - **The frontend:** its files are stored in the database and served from memory. Keep large media in [file storage](./file-storage.md) or a CDN. A deployment without a `public/` folder leaves the server serving its own `--public`. ## Binary + functions folder Like PocketBase with `pb_hooks`: one `onebase` binary loads your functions from the `onebase/` folder of the directory you run it in. Put the binary on your PATH, from the [releases](https://github.com/abdullah-bl/onebase/releases) or from `bun run build:binary`. It can't sit next to the `onebase/` folder, because they'd have the same name. ```bash sudo install onebase-linux-x64 /usr/local/bin/onebase ``` ``` server/ onebase/ your .ts functions (schema.ts, messages.ts, …) public/ optional frontend onebase_data/ created automatically: db.sqlite, files/, admin.key ``` ```bash cd server && onebase serve --port 8090 --url https://api.example.com ``` You update functions by copying files and restarting, with no rebuild. Functions can import npm packages from a `node_modules` folder next to them. ## Everything in one file `onebase build` compiles your functions, your `public/` frontend and the onebase runtime into a single executable: ```bash onebase build --out ./chat ``` ```bash onebase build --out ./chat-arm --target bun-linux-arm64 ``` ```bash ./chat --port 8090 --data ./onebase_data ``` The targets are `bun-linux-x64`, `bun-linux-arm64`, `bun-darwin-x64`, `bun-darwin-arm64` and `bun-windows-x64`. Run `build` from a project that has onebase installed (it needs the source). ## Configuration Every flag also has an environment variable, and environment variables can come from `.env` files: | Flag | Env | Default | |---|---|---| | `--port` | `PORT` | `8090` | | `--host` | `ONEBASE_HOST` | `0.0.0.0` | | `--url` | `ONEBASE_URL` | `http://localhost:`; the public URL, used in file URLs | | `--dir` | `ONEBASE_DIR` | `./onebase` | | `--data` | `ONEBASE_DATA` | `./onebase_data` | | `--public` | `ONEBASE_PUBLIC` | `./public` if it exists | | `--db` | `ONEBASE_DB`, `DATABASE_URL` | `/db.sqlite` (see [Databases](./databases.md)) | | `--files` | `ONEBASE_FILES` | `/files` (see [File storage](./file-storage.md)) | | `--backup` | `ONEBASE_BACKUP` | none (see [Backups](./backups.md)) | | `--backup-every` | `ONEBASE_BACKUP_EVERY` | `1h` | | `--backup-keep` | `ONEBASE_BACKUP_KEEP` | `24` | | `--cors` | `ONEBASE_CORS` | any origin; comma-separated list of allowed browser origins | | `--rate-limit` | `ONEBASE_RATE_LIMIT` | off; per-IP limit on calls, e.g. `100/10s` or `600/m` | | `--trust-proxy` | `ONEBASE_TRUST_PROXY=1` | off; read the client IP from `X-Forwarded-For` (set it behind a proxy) | | `--no-dashboard` | `ONEBASE_DASHBOARD=off` | dashboard on at `/_admin` (see [Dashboard](./dashboard.md)) | | `--no-deploy` | `ONEBASE_DEPLOY=off` | deploys on (see [onebase deploy](#onebase-deploy)) | | | `ONEBASE_ADMIN_KEY` | generated into `/admin.key` | ### .env files onebase reads `.env` files from the directory you run it in, so a server needs no long command line: | Command | Files, lowest priority first | |---|---| | `onebase dev` | `.env`, `.env.local`, `.env.development`, `.env.development.local` | | everything else (`serve`, `deploy`, binaries, …) | `.env`, `.env.local`, `.env.production`, `.env.production.local` | - **Priority:** later files win, flags win over everything, and real environment variables win over the files. So `PORT=9000 onebase serve` overrides a `PORT` in `.env`. - **Your own files:** `--env-file ` reads only the files you name (repeat it for several). - **Syntax:** the same as bun's, so a project behaves the same whether it runs from source or as a binary. That means `KEY=value`, optional `export`, `#` comments, quotes that may span lines, and `$VAR`, `${VAR}` and `${VAR:-default}` references. `#` starts a comment in unquoted values, so quote values that contain one, like `"https://x/#y"`. - **Your functions** see the same variables in `process.env`. - **`onebase dev`** restarts when a `.env` file changes. - **Keep secrets out of git:** the `.local` files are for secrets, and the usual `.gitignore` excludes them. On a server, a plain `.env` next to the data is fine. ## Behind a reverse proxy onebase uses WebSockets at `/api/sync`, so make sure your proxy forwards upgrades. With Caddy, it works out of the box: ``` api.example.com { reverse_proxy localhost:8090 } ``` With nginx: ```nginx location / { proxy_pass http://127.0.0.1:8090; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_read_timeout 3600s; } ``` ## systemd ```ini # /etc/systemd/system/onebase.service [Unit] Description=onebase After=network.target [Service] # onebase reads /srv/app/.env itself (PORT, ONEBASE_URL, secrets, …). WorkingDirectory=/srv/app ExecStart=/usr/local/bin/onebase serve Restart=always User=onebase [Install] WantedBy=multi-user.target ``` ```bash sudo systemctl enable --now onebase ``` ## Docker ```dockerfile FROM debian:bookworm-slim WORKDIR /app COPY onebase-linux-x64 /usr/local/bin/onebase COPY onebase/ ./onebase/ COPY public/ ./public/ VOLUME /app/onebase_data EXPOSE 8090 CMD ["onebase", "serve"] ``` Pass settings with `docker run --env-file .env`, or mount a `.env` into `/app`. To ship code with `onebase deploy` instead of baking it into the image, leave out the two `COPY` lines for `onebase/` and `public/`. The deployment lives in the database on the volume, so it survives a new container. ## Production checklist - **Public URL:** set `--url`, so file URLs point at your public domain. - **Durable data:** keep `onebase_data` on persistent disk, and add `--backup` (or use Postgres or Turso). - **Before deploying:** run `onebase doctor` (`onebase deploy` runs it for you), and try a risky schema change with `onebase deploy --dry-run`. - **Behind a proxy:** pass `--trust-proxy` so `--rate-limit` counts real client IPs. - **Dashboard:** keep the admin key secret, or block `/_admin` at the proxy (see [Dashboard](./dashboard.md)). - **Errors:** in production (no `--dev`), unexpected errors reach clients as `Server Error`. Use `ConvexError` for messages users should see. - **Memory:** expect about 40 MB at startup with SQLite; Postgres adds a few MB for its connection pool. --- # Dashboard Every onebase server includes a dashboard at **`/_admin`**. It's built into the binary (about 60 KB) and updates live. | Page | What you can do | |---|---| | **Overview** | Live connections and subscriptions, calls and errors, memory, a calls-per-second chart, table sizes, recent errors, and the database/files setup | | **Data** | Browse any table, filter (`{"author": "ali"}`) or look up an id, and add, edit or delete documents | | **Functions** | Run any function, internal ones included, with JSON arguments (prefilled from its validator), optionally as a signed-in user | | **Logs** | The last 1,000 calls, live: duration, source, errors and each call's `console.log` output | | **Scheduler** | Pending, failed and finished jobs (run now, cancel) and every cron with its next and last run | | **Files** | Uploaded files with previews; delete them | ## Signing in The dashboard is protected by an **admin key**: - **Generated key:** on first start, onebase generates one and saves it to `/admin.key`, readable only by its owner. - **Your own key:** set `ONEBASE_ADMIN_KEY` instead. Do this when you run several instances, so they all accept the same key. - **In dev:** `onebase dev` prints a link that signs you in directly. The key is in the URL's `#` fragment, which browsers never send to the server. ``` dashboard http://localhost:8090/_admin#key=… ``` In production the startup output says where the key is: ``` dashboard http://localhost:8090/_admin (admin key: onebase_data/admin.key) ``` The browser keeps the key in local storage until you sign out (⎋ at the bottom of the sidebar). Every API call is checked against it with a constant-time comparison. ## Editing data Edits go through the same path as a mutation: - **Validation:** they're checked against `schema.ts`, with the same error messages. - **Transactions:** each edit runs in a transaction. - **Live queries:** subscribed clients see the change immediately. **Saving replaces the whole document.** The editor shows the document without `_id` and `_creationTime`. Tables that have documents but are no longer in `schema.ts` still show up, marked "not in schema", so you can inspect old data. **Delete all** empties such a table. [Components](./components.md)' tables are listed under each component's name, and are checked against the component's schema when you edit them. Their functions show up under `components/` on the Functions page, where you can run them. ## Running functions **Functions** lists every query, mutation and action. The arguments box starts with an example built from the function's `args` validator. - **As a user:** tick "Run as a signed-in user" and edit the identity. `ctx.auth.getUserIdentity()` will return it. - **Side effects are real:** mutations and actions really run, so writes, scheduled jobs and outside calls all happen. ## Logs The runtime records every call: from clients, HTTP, subscription re-runs, the scheduler, crons and the dashboard. For each one it keeps the duration, the result or error, and any `console.*` output from inside the function, even when calls overlap. Records are kept in memory (the last 1,000), so they cost nothing on disk and reset on restart. Calls still go to the server's stdout as usual, so your log collector keeps working. ## Turning it off ```bash onebase serve --no-dashboard ``` You can also set `ONEBASE_DASHBOARD=off`. `/_admin` then returns 404. ## Security notes - **Admin key:** treat it like a database password. Anyone with it can read and change all data, and push code with [`onebase deploy`](./deployment.md#onebase-deploy). - **Exposure:** the dashboard is served on the same port as your API. If you don't want it reachable from the internet, block `/_admin` at your reverse proxy, or run with `--no-dashboard` and keep a separate internal instance with the dashboard on. - **Rotating the key:** delete `admin.key` and restart, or change `ONEBASE_ADMIN_KEY`. --- # Databases Pick a database with `--db`. Your functions don't change. | | `--db` | Instances | Notes | |---|---|---|---| | **SQLite** | a path (default `/db.sqlite`) | one | Fastest: reads take microseconds. The default. | | **Postgres** | `postgres://user:pass@host:5432/db` | many | Live queries are shared through `LISTEN/NOTIFY`. Works with Neon, Supabase, RDS, … | | **MySQL** | `mysql://user:pass@host:3306/db` | many | MySQL 8.0.21 or later. Schema indexes aren't used (see below). Not MariaDB. | | **Turso / libSQL** | `libsql://my-db.turso.io` | many | Token from `$TURSO_AUTH_TOKEN` or `?authToken=`. Also works with self-hosted `sqld`. | Postgres and MySQL support are built into Bun, and the libSQL client is pure JavaScript, so all four work from the same single binary. ## SQLite The default needs no setup. onebase uses WAL mode with a separate reader connection, so queries never wait for writes. Back it up to S3 with `--backup` (see [Backups](./backups.md)). The server gives space left by deleted documents back to the filesystem by itself; a database made by an older version needs one [`onebase vacuum`](./cli.md#vacuum) first. SQLite can only be used by one server process. When you need more than one, switch to Postgres, MySQL or Turso. ## Postgres ```bash onebase serve --db postgres://onebase:secret@db.internal:5432/app ``` - **Tables:** onebase creates its tables on first start: `documents` (with `jsonb`) plus a few system tables. - **Indexes:** each schema index becomes a `jsonb` expression index, and Postgres's planner uses it for both `withIndex` and `.filter()`. - **Isolation:** queries run in `REPEATABLE READ` read-only transactions, mutations in `SERIALIZABLE`. ## MySQL ```bash onebase serve --db mysql://onebase:secret@db.internal:3306/app ``` - **Tables:** `documents` (with a `JSON` column) plus the system tables, created on first start. - **Indexes:** MySQL can't index a JSON value, so schema indexes are recorded but not built. `withIndex` and `.filter()` give the same results as on the other databases, but each query reads the table's documents and sorts them. Fine for small and medium tables; use Postgres when tables get large. - **Search:** each search index is a `FULLTEXT` side table. MySQL skips words shorter than 3 characters and [stopwords](https://dev.mysql.com/doc/refman/8.4/en/fulltext-stopwords.html) like "the". - **Isolation:** queries run in `REPEATABLE READ` read-only snapshots, mutations in `SERIALIZABLE`. Deadlocks are retried automatically. - **Live queries** across instances use a small commit log that each instance polls every 200 ms, as with Turso. - **Connecting:** with MySQL 8's default `caching_sha2_password` and no TLS, add `?allowPublicKeyRetrieval=true` to the URL, or connect over TLS. **MariaDB isn't supported.** Its `JSON` type is plain text, so it compares and sorts document fields as strings (`"10" < "9"`). onebase stops at startup with an explanation rather than return wrong results. ## Turso / libSQL ```bash export TURSO_AUTH_TOKEN=... onebase serve --db libsql://my-app-myorg.turso.io ``` The schema and indexes are the same as with local SQLite. Each `ctx.db` call is a round trip to Turso, so put your server in the same region as the database. ## Running several instances With Postgres, MySQL or Turso you can run as many onebase processes as you like behind a load balancer: ```bash onebase serve --db $DATABASE_URL --files s3://bucket/files --port 8090 # machine A onebase serve --db $DATABASE_URL --files s3://bucket/files --port 8090 # machine B ``` What onebase guarantees: - **Live queries everywhere.** A commit on any instance updates subscribers on all of them: through `NOTIFY` on Postgres, and through a small commit log on MySQL and Turso that each instance polls every 200 ms. - **No lost updates.** Mutations are serializable. When two instances conflict, one of them is retried automatically. This is why side effects belong in actions, not mutations. - **Each job runs once.** Each scheduled job and cron run is claimed by exactly one instance. Use `--files s3://…` when you run several instances, so they all see the same files. Sticky sessions aren't required. ## How data is stored Every document is one row: `documents(id, tbl, ct, doc)`. The document is JSON (SQLite, MySQL) or `jsonb` (Postgres), `ct` is the creation time, and `tbl` is the table name. Schema indexes are partial expression indexes per table (not on MySQL). Ids carry a 4-character tag for their table. See [How it works](./how-it-works.md). --- # Backups With SQLite, onebase can take consistent snapshots of the database and upload them to S3, R2 or MinIO on a schedule. (Postgres and Turso have their own backup tools.) ## Automatic backups ```bash export S3_ACCESS_KEY_ID=... S3_SECRET_ACCESS_KEY=... S3_ENDPOINT=... # S3_ENDPOINT only for R2/MinIO/etc. onebase serve --backup s3://my-bucket/backups --backup-every 1h --backup-keep 24 ``` - **Snapshots:** `VACUUM INTO` gives a consistent copy without blocking writes. It's gzipped and uploaded as `db/onebase-