# Self-hosting

Source: https://dabadata.com/docs/self-hosting/ (HTML) - https://dabadata.com/docs/self-hosting/index.md (this page as markdown)

The whole of dabadata runs on your own machine or server with one command: the API, the viewer, this site, these docs and the live demo, on one port, over a Postgres database that is yours. All you need is Docker, or Bun and Postgres [without it](https://dabadata.com/docs/self-hosting/index.md#without-docker). The examples on this page use `http://localhost:4600`.

## What you get

`docker compose up` starts two containers: `postgres` (Postgres 16, data in a named volume) and `livedb` (the server, built from the repository; the service, user, database and volume keep the product's earlier name). Everything is served from port `4600`:

| Path | What |
| --- | --- |
| `/v1` | The [HTTP API](https://dabadata.com/docs/api/index.md) and the [live WebSocket](https://dabadata.com/docs/live/index.md) at `/v1/live`. |
| `/` | The viewer: tables, trace and the rules simulator. Open it with a view token, `/?token=<view token>` ([how to get one](https://dabadata.com/docs/api/index.md#view-token)). |
| `/www/` | The public site: home, why dabadata, pricing. |
| `/docs/` | These docs. |
| `/examples/demo/` | The live demo: two people on one database, with the trace underneath. Needs `DABADATA_DEMO=1`. |
| `/examples/todo/` | The shared to-do list example. |
| `/client/dist/` | The JavaScript client, `dabadata.js` (ES module) and `dabadata.iife.js` (a plain script tag). |

## Quick start

You need Docker with Compose (Docker Desktop, or Docker Engine with the compose plugin) and git.

```bash
git clone <the dabadata repository> dabadata
cd dabadata
docker compose up --build
```

The first build takes a minute or two. When the log says `dabadata server on ...`, open:

- [http://localhost:4600/examples/demo/](http://localhost:4600/examples/demo/) - the live demo
- [http://localhost:4600/www/](http://localhost:4600/www/) - the site, with these docs at `/docs/`

Add `-d` to run it in the background; `docker compose logs -f livedb` follows the server's log and `docker compose down` stops both containers and keeps the data.

The server applies `projects/default.ts` at every start: the demo's `tasks` and `announcements` tables. To run your own tables, edit that file (see [The default project](https://dabadata.com/docs/self-hosting/index.md#default-project), [Tables](https://dabadata.com/docs/tables/index.md) and [Rules](https://dabadata.com/docs/rules/index.md)) and run `docker compose up --build` again.

## Configuration

Every setting has a default, so no `.env` file is needed. To change one, put it in a `.env` file next to `docker-compose.yml` (Compose reads it by itself) or set it in the shell before `docker compose up`:

| Key | Default | Meaning |
| --- | --- | --- |
| `POSTGRES_PASSWORD` | `livedb` | Password of the `livedb` database user. Compose passes the same value to both containers. Set it before the first start: Postgres keeps the password it was created with. Use letters and digits, since it also goes into a URL. |
| `DABADATA_PROJECT_KEY` | `change-me-please` | The project's secret. Your own server uses it to mint user tokens and view tokens, and anything sent with it bypasses the rules. A new value takes effect at the next start. |
| `DABADATA_DEMO` | `1` | `1` turns on [`POST /v1/demo/tokens`](https://dabadata.com/docs/api/index.md#demo-tokens), which the live demo needs. `0` removes that route. |

The compose file sets the server's own keys for you. They are the keys of `.env.example`, which is what a server run without Docker reads ([The .env file](https://dabadata.com/docs/self-hosting/index.md#env)):

| Key | In Docker | Meaning |
| --- | --- | --- |
| `DATABASE_URL` | `postgres://livedb:<POSTGRES_PASSWORD>@postgres:5432/livedb` | The database the server uses: the `postgres` container. |
| `TEST_DATABASE_URL` | not set | Used by `bun test` only; the image does not run tests. |
| `PORT` | `4600` | HTTP and WebSocket port inside the container. To use another port on the host, change the left side of `ports`, for example `"8000:4600"`. |
| `DABADATA_HOST` | `0.0.0.0` | The interface the server binds. Inside a container it must be every interface; without Docker the default is `127.0.0.1`. |
| `DABADATA_PROJECT_NAME` | `default` | The server's own project, the *default project*; its definition is `projects/<name>.ts`. |
| `DABADATA_PROJECT_KEY` | from above | See above. |
| `DABADATA_DEMO` | from above | See above. |

A server without `DABADATA_PROJECT_KEY` has no default project: it logs `no default project`, serves only [hosted projects](https://dabadata.com/docs/self-hosting/index.md#several-projects), answers a call that names no project (no key, no token) with 401, and answers `POST /v1/demo/tokens` with 404 even in demo mode. Compose always sets the key, so this only happens without Docker.

The `livedb` service sets these explicitly and does not load a `.env` file into the container, so a `.env` left over from running dabadata without Docker cannot point it at another database. Only the three keys in the first table are read from it. Postgres has no published port; only the `livedb` container reaches it.

> **Security:** The defaults are for your own machine. On anything reachable from the internet, set a real `DABADATA_PROJECT_KEY` (for example the output of `openssl rand -hex 32`) and `DABADATA_DEMO=0`: demo mode hands out admin tokens to anyone who asks.

```bash
# .env next to docker-compose.yml, on a server
POSTGRES_PASSWORD=<a long random value>
DABADATA_PROJECT_KEY=<another long random value>
DABADATA_DEMO=0
```

### More settings

Every `DABADATA_` setting on this page is also read under its earlier name, `LIVEDB_` plus the same suffix, so an existing `.env` keeps working; when both are set, `DABADATA_` wins.

These keys are read by the server too; `.env.example` documents every one. Without Docker, put them in the repo `.env`. With Docker, the five marked **compose** are passed to the `livedb` service from the shell or a `.env` file next to `docker-compose.yml` (empty means off); for the others, add them to that service's `environment` in `docker-compose.yml`.

| Key | Default | Meaning |
| --- | --- | --- |
| `DABADATA_ADMIN_KEY` (compose) | empty | Turns on the [admin API](https://dabadata.com/docs/self-hosting/index.md#several-projects) for hosting several projects. At least 32 characters; anything shorter leaves it off (with a warning in the log). Empty = off, and every `/v1/admin/*` path answers 404. |
| `DABADATA_SECRETS_KEY` (compose) | empty | Encrypts [project secrets](https://dabadata.com/docs/projects/index.md#secrets): base64 of exactly 32 random bytes, for example the output of `openssl rand -base64 32`. Missing or malformed = a warning at start, the secrets routes answer 503 `secrets_unavailable`, and identity providers that use a `secret` verify nothing. |
| `DABADATA_BRAND` (compose) | `dabadata` | The display name [`GET /v1/meta`](https://dabadata.com/docs/api/index.md#meta) reports, and the name in mails, the hosted sign-in pages and the MCP server. |
| `DABADATA_TRUST_PROXY` (compose) | `0` | `1` = take the client IP for the per-IP rate limits from the first `X-Forwarded-For` entry. Set it only behind your own [reverse proxy](https://dabadata.com/docs/self-hosting/index.md#reverse-proxy), which overwrites that header; otherwise any client can claim any IP. `0` = the socket address. |
| `DABADATA_TRACE_DAYS` (compose) | empty | How many days of trace the nightly job keeps for the default project. Empty = keep everything. Hosted projects follow their plan. |
| `DABADATA_LOG_DAYS` | `1` | How many days of API request and realtime log the nightly job keeps for the default project. Hosted projects follow their plan's `logDays`. |
| `DABADATA_SQL_ROLES` | on | `0` turns the console's SQL page off (its routes answer 404), for a database user that may not create roles. Otherwise the SQL console is on whenever `DABADATA_SECRETS_KEY` (else `DABADATA_ADMIN_KEY`) is set: each environment gets two Postgres roles, `<schema>_ro` and `<schema>_rw`, with passwords derived from that key. |
| `DABADATA_PUBLIC_URL` | `http://127.0.0.1:<PORT>` | The server's public address, for example `https://db.example.com`. Every mailed link (console sign-in, invitations, your users' sign-in pages at `/auth/<project>/`), the MCP widgets' address and the OAuth metadata start with it. With `https` the session cookie is `Secure`. |
| `RESEND_API_KEY`, `MAIL_FROM` | empty | Both set = mail goes out through [Resend](https://resend.com/) from `MAIL_FROM` (for example `dabadata <no-reply@example.com>`). Otherwise the server writes every mail's subject and link to its log - fine on your own machine, never silent. |
| `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | empty | Both set = "Continue with Google" on the console's sign-in page. The OAuth client's redirect URI is `<DABADATA_PUBLIC_URL>/v1/auth/google/callback`. |
| `DABADATA_TEST_MAIL_DOMAIN`, `DABADATA_TEST_KEY` | empty | For end-to-end tests: mail to `@<domain>` is never sent but kept in memory and read at `GET /v1/test/mail` with `X-Test-Key`. Both must be set and the key at least 32 characters; otherwise the route answers 404 and the domain is an ordinary one. |
| `DABADATA_MAX_WORKSPACES_PER_ACCOUNT` | `3` | How many workspaces one console account may own, the one made at sign-up included. |
| `DABADATA_BETA_MAX_PLAN` | `starter` | With billing off, the highest plan an owner may pick in the console (`free`, `starter`, `pro`, `scale`). The admin API sets any plan. |
| `DABADATA_CONSOLE_SIGNUP` | `open` | Sign-up of new console accounts (not your apps' users). `waitlist`: an address without an account that confirms itself (link, Google, GitHub) goes on a waitlist instead; existing accounts, invited addresses and addresses approved with `POST /v1/admin/waitlist/approve` get in. `GET /v1/admin/waitlist` lists it. Any other value counts as `waitlist`. |

> **Security:** **Back up `DABADATA_SECRETS_KEY` apart from the database.** The secrets in the database cannot be read without it, so losing it means storing every project secret again, and anyone who has both the key and a database dump can read them all. Never change it on a server that already stores secrets.

## Behind a reverse proxy

To serve dabadata under your own domain with HTTPS, put a reverse proxy in front of port 4600. With the proxy on the same machine, publish the port on loopback only by changing `ports` to `"127.0.0.1:4600:4600"`. An nginx server block:

```nginx
server {
    listen 443 ssl;
    server_name db.example.com;
    # ssl_certificate and ssl_certificate_key as for any site

    # The live WebSocket: pass the upgrade on, keep quiet sockets open.
    location /v1/live {
        proxy_pass http://127.0.0.1:4600;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_read_timeout 1h;
        proxy_send_timeout 1h;
    }

    location / {
        proxy_pass http://127.0.0.1:4600;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
```

The API is at `/v1` and the pages link to each other with absolute paths (`/docs/`, `/www/`, `/examples/`), so host dabadata at the root of a domain or a subdomain, such as `db.example.com`, not under a path prefix such as `example.com/dabadata/`. The client and the demo switch to `wss://` by themselves when the page is served over HTTPS.

The block above sets `X-Forwarded-For`. If the server hosts projects, set `DABADATA_TRUST_PROXY=1` so their per-IP rate limits see the real client address rather than the proxy's.

## Your data

Everything dabadata keeps is in one plain Postgres database named `livedb`: its own tables (the project, the event log and mirrored records, tokens, the trace) in the `public` schema, and your declared tables in a schema named `p_<project>`, so `p_default` here. Each environment of a hosted project has a schema of its own, `p_<12 characters>` for live and the same with `_test` for test. You can read it with any Postgres tool. The data lives in the `livedb-pgdata` volume and survives restarts, rebuilds and `docker compose down`; only `docker compose down -v` deletes it.

A backup is one `pg_dump`; there is nothing else to back up:

```bash
# back up
docker compose exec -T postgres pg_dump -U livedb livedb > dabadata-backup.sql
# restore on a fresh volume, before dabadata first starts
docker compose up -d postgres
docker compose exec -T postgres psql -U livedb livedb < dabadata-backup.sql
docker compose up -d
```

## Operating several projects

One server can host many projects beside its default one, each with a `prj_` id, live and test environments, and its own keys - the projects described on [Projects and keys](https://dabadata.com/docs/projects/index.md), with the limits of [Plans and limits](https://dabadata.com/docs/limits/index.md). They are created and removed through the admin API, an operator tool: the people who build on a project never need it. Turn it on with `DABADATA_ADMIN_KEY` (and set `DABADATA_SECRETS_KEY` so projects can store secrets). Every admin call sends the admin key as a Bearer token; a missing or wrong key is a 401.

> **Security:** **The admin key creates, deletes and restores every project on the server.** Make it long and random (`openssl rand -hex 32`), keep it out of every client, and consider blocking `/v1/admin/` at your reverse proxy for everything but the machines you operate from.

### Create a workspace

A workspace holds projects and carries the plan: `free` (the default), `starter`, `pro`, `scale` or `enterprise`.

```bash
curl -X POST http://localhost:4600/v1/admin/workspaces \
  -H "Authorization: Bearer $DABADATA_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"Example shop","plan":"starter"}'
```

```json
{
  "workspace": {
    "id": "ws_example12345", "name": "Example shop", "slug": "example-shop", "plan": "starter",
    "planLimits": null, "limits": { "plan": "starter", "projects": 5, "connections": 200, ... },
    "createdAt": "2026-10-15T09:00:00.000Z"
  }
}
```

`GET /v1/admin/workspaces` lists them. `PATCH /v1/admin/workspaces/:id` takes `name`, `plan` and `planLimits`: an object that overrides single limits of the plan, each a non-negative whole number or `null` for unlimited, for example `{"planLimits":{"connections":5000}}`; `"planLimits": null` clears the overrides. A change applies at once (within 30 seconds on other server processes).

### Create a project

Creating a project creates both environments, their schemas and their keys, and, when you pass a definition (the [JSON form](https://dabadata.com/docs/tables/index.md#hosted)), applies it to both as version 1:

```bash
curl -X POST http://localhost:4600/v1/admin/projects \
  -H "Authorization: Bearer $DABADATA_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"workspaceId":"ws_example12345","title":"Shop"}'
```

```json
{
  "project": { "id": "prj_example12345", "title": "Shop", "workspaceId": "ws_example12345",
               "region": "il-central-1", "status": "active", "createdAt": "2026-10-15T09:00:00.000Z",
               "envs": { "live": { ... }, "test": { ... } } },
  "keys": {
    "live": { "publishable": "pk_live_EXAMPLEexample0000000000", "secret": "sk_live_EXAMPLEexample00000000000000000000000000" },
    "test": { "publishable": "pk_test_EXAMPLEexample0000000000", "secret": "sk_test_EXAMPLEexample00000000000000000000000000" }
  }
}
```

**This is the only time the secret keys are shown.** Only their hashes are stored, so hand the four keys and the project id to the project's owner straight away, over a channel you would trust with a password. A secret key that is lost cannot be shown again, but a member of the project's workspace (developer or above) gets a new one at any time by pressing **Rotate secret key** on the console's Keys page.

| Status | Meaning |
| --- | --- |
| `400` | A missing title, or a definition that does not validate (its messages start with `definition: `). A definition with a `secret` issuer cannot be applied here, since the project has no secrets yet: create the project without it, store the secret, then `PUT /v1/schema`. |
| `403` | `{ "error": "plan_limit", "limit": "projects", "message" }`: the workspace has all the projects its plan allows. Projects being deleted do not count. |
| `404` | No such workspace. |

`GET /v1/admin/projects` lists every hosted project, or one workspace's with `?workspaceId=`.

### Delete and restore

```bash
# schedule deletion: 410 project_deleted from now on, gone for good after 7 days
curl -X DELETE http://localhost:4600/v1/admin/projects/prj_example12345 \
  -H "Authorization: Bearer $DABADATA_ADMIN_KEY"
# undo it, any time within those 7 days
curl -X POST http://localhost:4600/v1/admin/projects/prj_example12345/restore \
  -H "Authorization: Bearer $DABADATA_ADMIN_KEY"
```

Both answer `{ "project" }`. Deleting sets both environments to `deleting`, closes their sockets and sets the deletion date 7 days ahead; the [nightly job](https://dabadata.com/docs/limits/index.md#nightly) then drops both schemas and every row of both environments (their usage history stays). Restoring sets both back to `active` and does not check the plan's project allowance.

### Run the nightly job

The server runs its nightly job by itself at 03:30 UTC, and at start when the last run is more than 25 hours old. To run it now - after changing a plan, or to test - call:

```bash
curl -X POST http://localhost:4600/v1/admin/jobs/nightly -H "Authorization: Bearer $DABADATA_ADMIN_KEY"
```

```json
{
  "ok": true,
  "report": {
    "startedAt": "2026-10-15T09:50:00.000Z", "finishedAt": "2026-10-15T09:50:00.214Z",
    "steps": {
      "dbBytes": { "ok": true, "environments": 3, "bytes": 1867776, "ms": 41 },
      "limitState": { "ok": true, "changed": 0, "over": [], "ms": 6 },
      "retention": { "ok": true, "trace": 120, "viewTokens": 1, "userTokens": 14, "refreshTokens": 2, "usageDays": 0, "ms": 18 },
      "idlePause": { "ok": true, "paused": [], "ms": 3 },
      "deletion": { "ok": true, "projects": [], "ms": 1 },
      "demoReset": { "ok": true, "skipped": "demo mode is off", "ms": 0 }
    }
  }
}
```

A step that fails is `ok: false` with an `error`, and the other steps still run. With `DABADATA_DEMO=1` the job also cleans up the demo: it deletes demo rooms idle for 2 hours (a sweep that also runs as visitors start new ones), then resets the default project - empties its tables, inserts the rows of `projects/<name>.seed.json` when that file exists, and deletes its trace and tokens. Several server processes on one database share the job, so it runs once.

## Sites

[Sites](https://dabadata.com/docs/sites/index.md) are served by a Cloudflare Worker from object storage, not by the dabadata server, so they need some Cloudflare set-up of your own. They are on when file storage is on (the four `DABADATA_FILES_R2_*` settings) and every one of these is set; otherwise every sites route answers 404 and the console hides the Sites page.

| Key | Default | Meaning |
| --- | --- | --- |
| `DABADATA_SITES_DOMAIN` | `dabadata.site` | The domain sites live under: `<name>.<domain>`. Use a registrable domain of its own, never a subdomain of the server's, so a site cannot set cookies for the console or the API. |
| `DABADATA_SITES_CNAME_TARGET` | `sites.dabadata.site` | The hostname owners point their custom domains at. |
| `DABADATA_SITES_R2_BUCKET` | empty | The R2 bucket for site files, apart from file storage's. The `DABADATA_FILES_R2_*` account and keys must cover it. |
| `DABADATA_SITES_CF_ACCOUNT_ID` | empty | The Cloudflare account. |
| `DABADATA_SITES_CF_ZONE_ID` | empty | The sites domain's zone, with Cloudflare for SaaS on and the CNAME target as its fallback origin. |
| `DABADATA_SITES_CF_API_TOKEN` | empty | A token with SSL and Certificates edit and Custom Hostnames edit on that zone, and Workers KV Storage edit on the account. |
| `DABADATA_SITES_KV_NAMESPACE_ID` | empty | The Workers KV namespace that maps hostnames to deploys. |

Deploy the Worker in `edge/sites/` with Wrangler, bound to the R2 bucket and the KV namespace, on the routes of the sites zone (which also carry every custom hostname through the SaaS fallback). The server writes the KV entries and creates the custom hostnames through the Cloudflare API; the Worker only reads.

## Updating

```bash
git pull && docker compose up --build -d
```

The new image replaces the old container; the data volume stays. Schema changes need no step of their own: at every start the server creates its own tables if they are missing and applies `projects/<name>.ts`, the same as a [schema reload](https://dabadata.com/docs/self-hosting/index.md#reload). Applying only adds, so an update never drops a column or a row.

## Without Docker

dabadata needs nothing beyond Bun and Postgres, so you can run it straight from a checkout of the repository:

- **Bun 1.3** - runs the server, the tests and the MCP server.
- **Postgres** - dabadata is developed against Postgres 16. Create two empty databases: one for the server and one for the tests.
- Optional: PHP, MariaDB and WP-CLI for the local WordPress test site.

From the repository root:

```bash
cp .env.example .env          # fill DATABASE_URL, the project key etc.
site/setup.sh                 # once, optional: installs WordPress + activates the plugin
scripts/dev.sh                # server on 4600, WordPress on 8080
scripts/stop.sh               # stops both
```

`scripts/dev.sh` builds the viewer the first time, starts the server in the background (its log goes to `temp-screenshots/server.log`) and starts WordPress if it is installed. To run only the server in the foreground:

```bash
cd server && bun install && bun run start
```

Started that way, build the viewer and console (`cd inspect && bun install && bun run build`) and [the client](https://dabadata.com/docs/self-hosting/index.md#client) once first. The server listens on `127.0.0.1` at `PORT`; set `DABADATA_HOST=0.0.0.0` only if it must be reachable from other machines. Check that it is up:

```bash
curl http://localhost:4600/v1/health
# {"ok":true}
```

### Run the checks

```bash
cd server && bun run typecheck && bun test      # uses the test database
cd client && bun run typecheck && bun test && bun run build
cd mcp && bun run typecheck
cd inspect && bun run typecheck && bun run build
```

### The .env file

The server reads `.env` at the repository root, whatever the working directory. A real environment variable wins over the file. `.env.example` documents every key:

| Key | Example | Meaning |
| --- | --- | --- |
| `DATABASE_URL` | `postgres://user:pass@127.0.0.1:5433/dabadata` | The database the server uses. |
| `TEST_DATABASE_URL` | `postgres://user:pass@127.0.0.1:5433/dabadata_test` | Used by `bun test` only; its name must contain `dabadata_test`. The tests leave it clean. |
| `PORT` | `4600` | HTTP and WebSocket port. |
| `DABADATA_PROJECT_NAME` | `default` | The server's own project, the default project. Its definition is `projects/<name>.ts`. |
| `DABADATA_PROJECT_KEY` | `change-me` | The project's secret. Anything sent with it bypasses the rules. Empty = no default project (the server then serves [hosted projects](https://dabadata.com/docs/self-hosting/index.md#several-projects) only). |
| `DABADATA_DEMO` | `0` | `1` turns on the public demo's token endpoint, [`POST /v1/demo/tokens`](https://dabadata.com/docs/api/index.md#demo-tokens), which hands out demo tokens without auth. Keep it `0` except on a throwaway demo project. |

On start the server creates its own tables if they are missing and registers the project named `DABADATA_PROJECT_NAME` with `DABADATA_PROJECT_KEY`. The other keys of `.env.example` - `DABADATA_ADMIN_KEY`, `DABADATA_SECRETS_KEY`, `DABADATA_BRAND`, `DABADATA_TRUST_PROXY`, `DABADATA_TRACE_DAYS` - are optional and described under [More settings](https://dabadata.com/docs/self-hosting/index.md#more-settings).

> **Security:** Replace `change-me` with a long random value, for example the output of `openssl rand -hex 32`. `.env` is gitignored; keep it that way.

## The default project

A self-hosted server has one project of its own, the **default project**, defined in a file and guarded by a single project key (`DABADATA_PROJECT_KEY`), which takes the place of a hosted project's secret key. Calls to it need no publishable key.

### Define a project

A project is a TypeScript file named after it. This is `projects/default.ts`, the demo's shared task list:

```ts
import { defineProject, t } from "../server/src/define";

// The demo project: a shared task list.
export default defineProject({
  name: "default",
  roles: ["admin", "editor", "viewer"],
  tables: {
    tasks: {
      columns: {
        title: t.text(),
        done: t.bool({ default: false }),
        owner: t.userId(),
        due: t.date({ optional: true }),
        notes: t.json({ optional: true }),
      },
      rules: {
        read: "authenticated",
        create: { role: ["admin", "editor"] },
        update: { any: [{ role: "admin" }, { owner: "owner" }] },
        delete: { role: "admin" },
      },
    },
    announcements: {
      columns: {
        text: t.text(),
        author: t.userId(),
      },
      rules: {
        read: "anyone",
        create: { role: "admin" },
        update: { role: "admin" },
        delete: { role: "admin" },
      },
    },
  },
});
```

Read it out loud and it is the policy: anyone signed in can read tasks, admins and editors can create them, an admin or the task's owner can update one, only admins delete. Announcements are public to read and admin-only to change. [Tables](https://dabadata.com/docs/tables/index.md) and [Rules](https://dabadata.com/docs/rules/index.md) cover every option.

### Reload the schema

The server applies the file when it starts. After you edit it, ask the running server to apply it again:

```bash
curl -X POST http://localhost:4600/v1/schema/reload \
  -H "Authorization: Bearer <project key>"
```

```json
{ "ok": true, "applied": ["add column tasks.due"], "drift": [] }
```

`applied` lists what was created this time; `drift` lists anything in the database the file no longer describes. An invalid file is answered with a 400 and every problem it found, and the previous definition stays in force.

### Mint a user token

End users never see the project key. Your backend signs the person in its own way, then asks dabadata for a token that carries their id, name and role:

```bash
curl -X POST http://localhost:4600/v1/users/token \
  -H "Authorization: Bearer <project key>" \
  -H "Content-Type: application/json" \
  -d '{"id":"alice","name":"Alice","role":"admin"}'
```

```json
{
  "token": "<user token>",
  "expiresAt": "2026-10-15T10:30:00.000Z",
  "refreshToken": "<refresh token>",
  "refreshExpiresAt": "2026-11-14T09:30:00.000Z"
}
```

The role must be one the project declares. `token` is the access token and lives 1 hour; `refreshToken` lives 30 days and gets the next pair ([Refreshing](https://dabadata.com/docs/identity/index.md#refresh)). Pass all of it to the client: given the refresh token, the client refreshes by itself. For local testing, `scripts/demo-tokens.sh` mints three users at once - alice (admin), bob (editor) and carol (viewer) - and prints ready-to-open URLs for the to-do example.

### The client, built from source

Build the JavaScript client once; the server then serves the bundles at `/client/dist/`, the same files the hosted service serves:

```bash
cd client && bun install && bun run build
# dist/dabadata.js       ES module: createClient, DabadataError
# dist/dabadata.iife.js  script tag: window.dabadata = { createClient, DabadataError }
```

In a TypeScript project inside the checkout you can also import `client/src/index.ts` directly; the types come with it. Then, with Alice's token from above (no `key`: this is the default project):

```html
<script type="module">
  import { createClient } from "http://localhost:4600/client/dist/dabadata.js";

  const db = createClient({
    url: "http://localhost:4600",
    token: "<user token>",
    expiresAt: "<expiresAt>",
    refreshToken: "<refresh token>",
  });

  db.subscribe("tasks", {}, {
    onRows: (rows) => console.log("tasks now", rows),
    onChange: ({ op, row }) => console.log(op, row),
  });

  await db.insert("tasks", { title: "Buy milk" });
</script>
```

Alice is an admin, so the insert is allowed. dabadata fills `owner` with her id and `done` with its default, and every open subscription that may read the new row - this tab and any other - gets an `insert` change. Try the same insert with carol's token and you get a `DabadataError` whose `explanation` reads `create tasks: denied, role "viewer" not in ["admin","editor"]`.

### Open the demo

The [live demo](https://dabadata.com/demo/) puts two people side by side on one database: pick who each pane is, change something on one side and watch it land on the other, with the trace underneath explaining every decision. Every visitor gets a private copy - a demo room in one shared demo database, started from `projects/default.ts` and its seed file, removed after 2 hours without use - so visitors never see each other's rows ([POST /v1/demo/tokens](https://dabadata.com/docs/api/index.md#demo-tokens)). It needs `DABADATA_DEMO=1` on a throwaway project. The viewer is at `/?token=<view token>`; get a view token with `POST /v1/view-token` (see the [API reference](https://dabadata.com/docs/api/index.md#view-token)).
