# JavaScript client

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

The dabadata JavaScript client is a small library with no dependencies that wraps `/v1/mutate`, `/v1/db` and the live socket. It runs in browsers and in any runtime with a global `fetch` and `WebSocket`; elsewhere, pass your own through the options.

Writing a client for another platform (Dart, Swift, Kotlin)? Everything this client does on the wire, from token refresh timings to the realtime transport's recovery, is specified on [Wire protocol](https://dabadata.com/docs/protocol/index.md), with a conformance suite that checks a port case by case.

## Load it

The hosted service serves the client, ready to use, next to its types:

| URL | What |
| --- | --- |
| `https://dabadata.com/client/dist/dabadata.js` | ES module: `createClient`, `DabadataError`. |
| `https://dabadata.com/client/dist/dabadata.iife.js` | Plain script tag: sets `window.dabadata = { createClient, DabadataError }`. |
| `https://dabadata.com/client/dist/dabadata.d.ts` | The TypeScript types of the ES module. |
| `https://dabadata.com/client/v0.2.0/dabadata.js` | The same files, pinned to one released version (`dabadata.iife.js` and `dabadata.d.ts` too). See [Versions and changes](https://dabadata.com/docs/client/index.md#versions). |

`/client/dist/` is always the newest client. For a production app, load a pinned version from `/client/v<version>/`: its files never change, so what you tested is what your users run. Both answer any origin (CORS), so the import works from your own site.

As an ES module:

```html
<script type="module">
  import { createClient, DabadataError } from "https://dabadata.com/client/dist/dabadata.js";
  const db = createClient({ url: "https://dabadata.com", key: "pk_test_EXAMPLEexample0000000000", token: "<user token>" });
</script>
```

With a plain script tag:

```html
<script src="https://dabadata.com/client/dist/dabadata.iife.js"></script>
<script>
  const db = dabadata.createClient({ url: "https://dabadata.com", key: "pk_test_EXAMPLEexample0000000000", token: "<user token>" });
</script>
```

In a TypeScript or bundled project (Vite, React, Vue ...), save `dabadata.js` and `dabadata.d.ts` side by side in your source, for example as `src/lib/dabadata.js` and `src/lib/dabadata.d.ts`, and import `./lib/dabadata.js`: TypeScript picks the types up from the file next to it. A server of your own serves the same files at `/client/dist/` once the client is built ([The client, built from source](https://dabadata.com/docs/self-hosting/index.md#client)).

### Migrating from livedb

dabadata was called livedb before. Pages written then keep working: the same bundles are also served as `livedb.js` and `livedb.iife.js`, the script tag also sets `window.livedb`, `LiveDbError` is another name of `DabadataError`, and a session stored under the old `livedb.auth:` key is moved to the new one. Hosted users' tokens issued under the old issuer name `livedb` are still accepted, so that name is reserved in `auth.issuers` like `hosted`. Switch to the new names when you next touch the page.

## createClient(options)

```ts
function createClient(options: ClientOptions): DabadataClient
```

| Option | Type | Default | Meaning |
| --- | --- | --- | --- |
| `url` | `string` | required | Server base URL: `https://dabadata.com`, or your own server's address. A trailing slash is fine. |
| `key` | `string \| null` | none | The environment's publishable key (`pk_live_...` or `pk_test_...`), sent with every call. Needed on a [hosted project](https://dabadata.com/docs/client/index.md#hosted) for anonymous calls, provider JWTs and `db.auth`; leave it out on a self-hosted server's default project. |
| `storage` | `"local" \| "memory"` | `"local"` | Where [`db.auth`](https://dabadata.com/docs/client/index.md#auth) keeps the signed-in user's tokens: `localStorage` or memory only. See [Where the session is kept](https://dabadata.com/docs/client/index.md#storage). |
| `token` | `string \| null` | none | A user (access) token from `POST /v1/users/token`. Leave it out for anonymous calls, or to use `db.auth` (and a stored session). |
| `refreshToken` | `string \| null` | none | The refresh token from the same call. With it the client renews the access token by itself ([below](https://dabadata.com/docs/client/index.md#tokens)). |
| `expiresAt` | `string \| number \| null` | none | When `token` expires: the ISO string from the server, or milliseconds since the epoch. |
| `onTokens` | `(tokens: TokenSet) => void` | none | Called with every new pair after a refresh, so the app can persist it. |
| `refreshUrl` | `string` | `<url>/v1/users/refresh` | Where refreshes are sent. |
| `reconnectDelays` | `number[]` | `[1000, 2000, 4000, 8000, 10000]` | Milliseconds to wait before each reconnect attempt. The last one repeats. |
| `pingInterval` | `number` | `25000` | Milliseconds between pings on the socket. |
| `fetch` | `FetchLike` | `globalThis.fetch` | Override for tests or runtimes without a global `fetch`. |
| `WebSocket` | `typeof WebSocket` | `globalThis.WebSocket` | Override for tests or runtimes without a global `WebSocket`. |

The client belongs to one person at a time. With [`db.auth`](https://dabadata.com/docs/client/index.md#auth) it switches by itself: a sign-in or sign-out reconnects every subscription as the new user. With tokens you pass in yourself, `close()` the old client when someone else signs in, or the person signs out, and create a new one.

## Tokens and refreshing

User tokens live 1 hour. Give the client the whole pair your server minted - `token`, `expiresAt` and `refreshToken` - and it keeps itself signed in:

- **Ahead of time.** It refreshes 60 seconds before `expiresAt` (never sooner than 5 seconds from when it schedules the refresh).
- **On a 401.** When any HTTP call answers 401, it refreshes once and retries the call once.
- **On the socket.** When a socket closes before it opened while the access token is expired, or its expiry is unknown, it refreshes (once per streak of failed connects) and the reconnect uses the new token. An open socket is kept across a refresh; the next reconnect uses the new token.
- **One at a time.** Refreshes that would overlap share one request.
- **Every new pair goes to `onTokens`**, so you can store it for the next visit. The old refresh token is dead the moment it is used, so always keep the newest one.
- **When a refresh fails with 401** (the refresh token expired, was used already or was revoked), the client drops the refresh token and the original error surfaces. Send the person back through your sign-in to get a new pair.

```js
const saved = JSON.parse(localStorage.getItem("dabadata.tokens") || "null");

const db = createClient({
  url: "https://dabadata.com",
  key: "pk_live_EXAMPLEexample0000000000",
  token: saved?.token,
  expiresAt: saved?.expiresAt,
  refreshToken: saved?.refreshToken,
  // Keep the newest pair: the refresh token it replaces no longer works.
  onTokens(tokens) {
    localStorage.setItem("dabadata.tokens", JSON.stringify(tokens));
  },
});
```

`TokenSet` is the shape both `/v1/users/token` and `/v1/users/refresh` answer with: `{ token, expiresAt, refreshToken, refreshExpiresAt }`. Treat the refresh token like a password; see [the Security note on Identity and tokens](https://dabadata.com/docs/identity/index.md#revoke). Without a refresh token the client uses the access token until it expires; after that, calls answer 401.

## With a hosted project

Give the client the environment's publishable key as `key`. It sends it as `X-Api-Key` on every HTTP call (refreshes included) and as `?key=` on the socket, so the server knows which project every call is for:

```js
const db = createClient({
  url: "https://dabadata.com",
  key: "pk_live_EXAMPLEexample0000000000", // safe in a browser: it grants nothing by itself
  token, expiresAt, refreshToken,          // a signed-in user; leave out for anonymous
});
```

Anonymous calls and provider JWTs (Google, Clerk, Auth0, ...) carry nothing that names your project, so they need the key. A token dabadata minted - from your backend, or from [`db.auth`](https://dabadata.com/docs/client/index.md#auth) - knows its environment and works with or without it; with it, the two must belong to the same environment. Send the key on every client of a hosted project: it costs nothing and is what `db.auth` needs. Leave `key` out only on a self-hosted server's default project. See [How a call finds its project](https://dabadata.com/docs/projects/index.md#resolution).

The console's **Keys -> Snippets** tab builds this code from your real keys for plain HTML, Vite / React / Vue, a Node backend, WordPress and curl.

## db.auth

When the environment [hosts your users' accounts](https://dabadata.com/docs/end-user-auth/index.md), `db.auth` signs them up, in and out. It needs `key`. A sign-in stores the token pair, schedules the refresh, calls `onTokens`, and reconnects every open subscription as the new user, so their `onRows` run again with what that user may see. Signing out reconnects them as anonymous.

| Method | Resolves | Does |
| --- | --- | --- |
| `signUp({ email, password, name?, redirectUrl? })` | `SignUpResult` | Creates the account. With email confirmation on (the default) a link is mailed: `{ user: null, verify: true, signupSecret }`. Without, the person is signed in: `{ user, verify: false, signupSecret: null }`. |
| `signIn({ email, password })` | `AuthUser` | Signs in. Rejects with `code` `invalid_login`, `email_unverified`, `user_disabled` or `rate_limited`. |
| `signInWithLink({ email, redirectUrl? })` | `void` | Mails a 15-minute sign-in link. |
| `providers()` | `AuthProviders` | `{ providers, page }`: the [sign-in providers](https://dabadata.com/docs/sign-in-providers/index.md) whose browser flow is on, in the server's order (`["google", "github"]`), and `page`, the hosted pages' address (`https://dabadata.com/auth/prj_..._test/`). Ids this client does not know are left out. |
| `signInWithProvider(provider, { redirectUrl? })` | `void` | Leaves the page for the provider, through `<page><provider>?redirect=`. `redirectUrl` must be one of the project's `redirectUrls`; the default is the current URL without its fragment. Call `completeLink()` on the page the person comes back to. Rejects with `code` `provider_not_enabled` when the provider is not in `providers()`, and `unsupported` outside a browser. |
| `signInWithIdToken({ provider, idToken, nonce?, name? })` | `AuthUser` | Signs in with an ID token your app got from Google or Apple itself ([native apps and your own buttons](https://dabadata.com/docs/sign-in-providers/index.md#native)), like `signIn`. `provider` is `"google"` or `"apple"`; `nonce` is the raw nonce when the token carries one; `name` is used only when a user is created. Rejects with `code` `invalid_token`, `provider_not_enabled`, `email_missing`, `email_unverified`, `signup_closed`, `user_disabled`, `on_signup_failed`, `validation` or `rate_limited`. |
| `completeLink({ url?, password?, signupSecret? })` | `AuthUser \| null` | Finishes the link the page was opened with (`url`, default `location.href`): the hosted pages' redirect (`#token=&refreshToken=...`) or an app-mode link (`#token=...&type=verify\|magic\|reset\|invite`). Clears the fragment. Null when there is no `#token`. |
| `resetPassword({ email, redirectUrl? })` | `void` | Mails a 1-hour reset link. |
| `signOut({ everywhere? })` | `void` | Revokes this session's tokens (or, with `everywhere`, all of the user's), forgets them locally and reconnects as anonymous. The local sign-out happens even if the server cannot be reached. |
| `user({ force? })` | `AuthUser \| null` | The signed-in user, cached; `force` asks the server. Null when signed out or when the token is no longer valid. |
| `update({ name?, password?, currentPassword? })` | `AuthUser` | Changes the signed-in user's name (`null` clears it) and / or password. A new password needs `currentPassword` when the user has one (wrong: `code` `invalid_login`, status 403), signs every other session out, and the new token pair the server answers is stored like a sign-in, so this page stays signed in. Fires `"update"`. |
| `onAuthChange(cb)` | `() => void` | Calls `cb(user, event)` with `event` `"signIn"`, `"signOut"`, `"refresh"` or `"update"`. Returns the function that stops it. |
| `invite({ email, name?, role?, redirectUrl? })`, `invites()`, `cancelInvite(id)` | `void`, `Invite[]`, `void` | Invites from the app, under the project's `auth.users.invite` rule ([Invite people from your app](https://dabadata.com/docs/end-user-auth/index.md#invites)). |
| `admin.listUsers({ query?, limit?, after? })` | `UserList` | For users who pass the project's `auth.users.manage` rule ([App admins](https://dabadata.com/docs/end-user-auth/index.md#app-admins)): `{ users, next }`, newest first; each user with `email`, `role`, `disabled`, `lastSignInAt`, `hasPassword`, `providers` (the social providers linked to them). |
| `admin.setRole(id, role)`, `admin.setName(id, name)`, `admin.disable(id)`, `admin.enable(id)` | `ManagedUser` | Changes one user. Rejects with `code` `manage_not_allowed`, `role_not_allowed` (outside `auth.users.manageRoles`) or `self_not_allowed` (your own role, disabling yourself). |
| `admin.remove(id)` | `void` | Deletes the user and signs them out everywhere. |

- **Links.** A reset or invite link sets a password: call `completeLink({ password })`. Without one it rejects with `code: "password_required"` and leaves the link in the URL, so you can show a password field and call it again. A dead link (`link_invalid`) is removed from the URL.
- **The sign-up secret.** `signUp` keeps the answer's `signupSecret` for 24 hours and `completeLink` sends it with the confirmation link. Only then does the password chosen at sign-up survive; a confirmation opened elsewhere signs the person in without it ([why](https://dabadata.com/docs/end-user-auth/index.md#pre-registration)). An app that carries the secret itself passes `completeLink({ signupSecret })`.
- **Refreshes** of a `db.auth` session are stored and fire `"refresh"`. A refresh the server refuses ends the session: it is cleared and `"signOut"` fires.

```js
const db = createClient({ url: "https://dabadata.com", key: "pk_test_EXAMPLEexample0000000000" });

db.auth.onAuthChange((user, event) => console.log(event, user?.email ?? "signed out"));

// Back from a mailed link or the hosted pages? Finish signing in.
await db.auth.completeLink();

if (!(await db.auth.user())) {
  await db.auth.signIn({ email: "someone@example.com", password: "correct horse battery" });
}
```

```js
// A "Continue with ..." button for each provider that is on.
const { providers } = await db.auth.providers();
for (const p of providers) {
  const b = document.createElement("button");
  b.textContent = `Continue with ${p}`;
  b.onclick = () => db.auth.signInWithProvider(p, { redirectUrl: "https://tasks.example.com/" });
  document.body.append(b);
}
// Back on https://tasks.example.com/ the completeLink() call above signs the person in.
```

A complete page - sign up, links, sign in, sign out and a live list - is on [Sign-in for your app's users](https://dabadata.com/docs/end-user-auth/index.md#example); one with provider buttons is on [Sign in with Google, Apple and more](https://dabadata.com/docs/sign-in-providers/index.md#client).

### Where the session is kept

The `storage` option says where `db.auth` keeps the signed-in user's token pair:

- `"local"` (the default): `localStorage`, one entry per server URL and key (`dabadata.auth:<url>:<key>`). The next `createClient` on the page - after a reload, in another tab - picks the session up, as long as it is given neither `token` nor `refreshToken` and the refresh token has not expired (an expired entry is removed). Where `localStorage` is missing or blocked, the client falls back to memory.
- `"memory"`: nothing is written; the session ends with the page. The sign-up secret is then kept in memory too, so the confirmation link must be completed by the same page.

Only `db.auth` sessions are stored. Tokens you pass to `createClient` yourself are yours to keep, with [`onTokens`](https://dabadata.com/docs/client/index.md#tokens).

## insert

```ts
insert<R = Row>(table: string, values: Record<string, unknown>): Promise<R>
```

Inserts one row and resolves with it as stored - with its `id`, defaults, filled-in `userId` columns and timestamps. Like every write, it resolves with only `{ id }` when your read rule does not let you read the row, and without the columns your [column read rules](https://dabadata.com/docs/rules/index.md#column-read-rules) hide.

```js
const task = await db.insert("tasks", { title: "Order chairs", due: "2026-10-20" });
console.log(task.id, task.owner, task.done); // "5f1c...", "bob", false
```

## update

```ts
update<R = Row>(table: string, id: string, values: Record<string, unknown>): Promise<R>
```

Changes the given columns of one row and resolves with the row after the change.

```js
const done = await db.update("tasks", task.id, { done: true });
```

## delete

```ts
delete(table: string, id: string): Promise<{ id: string }>
```

Deletes one row and resolves with its id.

```js
await db.delete("tasks", task.id);
```

## action

```ts
action<R = Row>(name: string, params?: Record<string, unknown>): Promise<{ results: { row: R | null }[]; traceId: number; traceIds: number[]; ai?: ActionAiResult }>
```

Runs one of the definition's [actions](https://dabadata.com/docs/rules/index.md#actions-protecting-counters-capacity-and-stock) (`POST /v1/actions/<name>`): its steps run in one transaction with the server's rights, if you pass its `allow` rule. `results` holds one `{ row }` per step, `null` where your read rule does not let you see the row.

```js
try {
  await db.action("rsvp", { event: eventId });
} catch (e) {
  if (e.status === 409 && e.body.index === 0) showFull();     // step 0, the capacity check, did not hold
  else if (e.status === 403) console.log(e.explanation);      // "action rsvp: denied, anonymous is not authenticated"
  else throw e;
}
```

A refusal rejects with a `DabadataError`: 403 `forbidden` (the allow rule, with the explanation), 400 bad params (`e.body.messages`), 404 a missing row, 409 `conflict` with `e.body.index` naming the step.

An action that [calls a model](https://dabadata.com/docs/ai/index.md) also resolves with `ai`: `{ status: "done", text, stopReason, usage, traceId }` when the call was waited for, `{ status: "failed", error, traceId }` when it failed after the steps committed, or `{ status: "queued", id }` when the answer is written to a row in the background (subscribe to the row to see it arrive). Over the action's limits it rejects with 429 `e.code` `rate_limited` or `ai_busy` (`e.body.retryAfter` seconds), and an action with no steps whose call failed rejects with 502 `ai_failed`.

## query

```ts
query<R = Row>(table: string, options?: QueryOptions): Promise<R[]>
```

One read of the rows the caller may see.

| Option | Type | Meaning |
| --- | --- | --- |
| `where` | `Record<string, unknown>` | Column -> value (equality), or operators: `$eq $ne $gt $gte $lt $lte $in $nin $like $ilike $null`, and for json columns `$contains` (an array holding a value, an object holding a sub-object); `$and` / `$or` nest filters. See [the protocol](https://dabadata.com/docs/protocol/index.md#reading). |
| `orderBy` | `string \| string[]` | A declared column or `created_at`, or several: `"due,-created_at"` or `["due", "-created_at"]` (`-` = descending). Default: `created_at`, newest first. |
| `desc` | `boolean` | Descending order for a single `orderBy` column. |
| `limit` | `number` | Default 100, at most 1000. |

```js
const open = await db.query("tasks", { where: { done: false }, orderBy: "due", limit: 50 });
const soon = await db.query("events", {
  where: { starts_at: { $gte: new Date().toISOString() }, tags: { $contains: "music" } },
  orderBy: ["starts_at", "-created_at"],
});
```

There is no single-row method. To fetch one row by id, call [`GET /v1/db/:table/:id`](https://dabadata.com/docs/api/index.md#db-row) with the same token.

## view and viewRow

```ts
view<R = Row>(name: string, options?: ViewOptions): Promise<{ rows: R[]; traceId: number }>
viewRow<R = Row>(name: string, id: string, options?: { params?: Record<string, unknown> }): Promise<{ row: R; traceId: number }>
```

Reads a declared [view](https://dabadata.com/docs/views/index.md) (`GET /v1/views/<name>`): the rows it lets you read, in your role's shape, computed fields included. `viewRow` reads one row of it by id.

| Option | Type | Meaning |
| --- | --- | --- |
| `params` | `Record<string, unknown>` | The view's params, of the types it declares. |
| `where` | `Where` | A filter, as for `query`, on the view's `filter` columns only. |
| `orderBy`, `desc` | `string \| string[]`, `boolean` | A sort on the view's `sort` columns. Default: the view's own. |
| `limit` | `number` | Clamped to the view's `max`. |

```js
const { rows } = await db.view("open_questions", { params: { round: roundId }, orderBy: "reveal_at" });
const { row } = await db.viewRow("open_questions", rows[0].id, { params: { round: roundId } });
```

A view with [joins](https://dabadata.com/docs/views/index.md#joins) nests each joined row under the join's name: `row.question.text`. `JoinedRow` types it, and a column the joined table's column rules hide from you is absent from the nested row as well:

```ts
import type { JoinedRow, Row } from "./lib/dabadata.js";

const { rows } = await db.view<JoinedRow<{ question: Row }>>("my_answers");
for (const a of rows) console.log(a.choice, a.question.text, a.question.answer ?? "(not revealed yet)");
```

A refusal rejects with a `DabadataError`: 404 an unknown view (or a row not in it), 403 `forbidden` from the view's `allow` rule (with the explanation), 400 bad params or a `where` / `orderBy` on columns the view does not declare (`e.body.messages`).

## subscribe

```ts
subscribe<R = Row>(target: string | { view: string; params?: Record<string, unknown> }, options?: SubscribeOptions, handlers?: SubscribeHandlers<R>): Subscription
```

Opens the socket if needed, subscribes, and calls your handlers. `target` is a table name, or `{ view, params }` for a [view](https://dabadata.com/docs/views/index.md). `options` takes a `where` map, as for `query` (for a view: on its filter columns).

| Handler | Called with | When |
| --- | --- | --- |
| `onRows(rows)` | `R[]` | The full result set: first, again after every reconnect, and whenever the server resets the subscription (a definition change, a time flip of a whole rule, a membership change, a joined row that may let rows in, too many changes at once). Treat it as a reset. |
| `onChange(change)` | `{ op, row }` | Every change you may see, including rows that enter or leave your view by time with no write. `op` is `"insert"`, `"update"` or `"delete"`; for deletes `row` may be only `{ id }`. An update carries the whole visible row: replace the row you hold, do not merge. |
| `onError(error)` | `DabadataError` | The subscription was refused (`code: "forbidden"`, status 403) or failed (status 0, e.g. an unknown table), or there is no `WebSocket` to use. |
| `onStatus(status)` | `ConnectionStatus` | The socket's status changed. Also called right away if the socket already exists. |

It returns a `Subscription`: `{ id, table, view?, close() }` - for a view, `table` and `view` are the view's name. `close()` ends this subscription only; the socket stays open for the others.

```js
const tasks = new Map();

const sub = db.subscribe("tasks", { where: { done: false } }, {
  onRows(rows) {
    tasks.clear();
    for (const row of rows) tasks.set(row.id, row);
    render();
  },
  onChange({ op, row }) {
    if (op === "delete") tasks.delete(row.id);
    else tasks.set(row.id, row); // insert, or update of a row that may be new to us
    render();
  },
  onError(err) {
    console.warn(err.explanation ?? err.message);
  },
  onStatus(status) {
    document.body.dataset.connection = status;
  },
});

// later
sub.close();
```

A handler that throws does not break the socket; the error is rethrown on its own tick so it still shows up in the console.

## db.storage

Files in the project's [storage buckets](https://dabadata.com/docs/storage/index.md). Uploads go straight to object storage through a signed URL; the server checks the bucket's rules, sizes and types first.

```js
const file = await db.storage.upload("avatars", input.files[0], {
  name: "me.png",                                   // default: the File's name
  type: "image/png",                                // default: the Blob's type
  onProgress: ({ loaded, total }) => console.log(loaded / total),
});                                                 // -> the row of files_avatars
const src = await db.storage.url("avatars", file.id);       // public URL, or a 5-minute signed URL
const { url, expiresAt, expiresIn, publicUrl } = await db.storage.signedUrl("docs", id);
const stop = db.storage.bindImage(img, "photos", row.photo);  // sets src; a fresh URL when the old one expired
const stopLink = db.storage.watchUrl("docs", id, (url) => (link.href = url)); // renews before expiry
await db.storage.remove("avatars", file.id);                // -> { id }
const rows = await db.storage.list("avatars", { where: { owner: "u1" }, orderBy: "size", desc: true, limit: 20 });
db.subscribe("files_avatars", {}, { onChange: (change) => console.log(change) });
```

`onProgress` reports as the bytes go out when the runtime has `XMLHttpRequest` (browsers); otherwise only at the start and the end. A refused call rejects with a [DabadataError](https://dabadata.com/docs/client/index.md#errors) carrying the rule's `explanation`; a failed upload PUT has `code: "upload_failed"`.

A signed URL lives 5 minutes. On a page that stays open, use `bindImage` for an `<img>` or `<video>` and `watchUrl` for anything else; both return `stop()` and take `{ onError }`. See [Private files on pages that stay open](https://dabadata.com/docs/storage/index.md#renew).

`attachTo: { table, id, column }` then sets a [file column](https://dabadata.com/docs/storage/index.md#file-columns) of that row to the new file (an `update`, with the row's rules); the file it replaces is deleted by the server. When the update is refused, the thrown `DabadataError` carries the uploaded file as `file`.

## token, setToken and refresh

```ts
readonly token: string | null
setToken(token: string | null, expiresAt?: string | number | null): void
refresh(): Promise<TokenSet>
```

- `db.token` is the access token in use now: the one a `db.auth` sign-in stored, or the one you passed. It changes after every refresh, so read it when you need it rather than keeping a copy. Use it to call a route the client has no method for, with the same key: `` fetch(url, { headers: { "X-Api-Key": key, Authorization: `Bearer ${db.token}` } }) ``.
- `db.setToken(token, expiresAt?)` swaps the access token (a new one from your own backend, or `null` to go anonymous) and reschedules the refresh. An open socket keeps the identity it opened with; the next connect uses the new token. It does not touch the refresh token or a stored `db.auth` session - for those use `db.auth`.
- `db.refresh()` refreshes now (overlapping calls share one request) and resolves the new pair.

## transport, presence and publish

```ts
readonly transport: "inproc" | "centrifugo" | null
presence(table: string): Promise<PresenceEntry[]>           // PresenceEntry = { id, name?, role }
publish(table: string, data: unknown): Promise<void>
onMessage<T>(table: string, cb: (data: T, from: PresenceEntry | null) => void): () => void
```

- `db.transport` is how live changes arrive: `"inproc"` (the server's own `/v1/live` socket) or `"centrifugo"` (the realtime tier, on https://dabadata.com). It is `null` until the client has asked the server (`GET /v1/meta`, once, on the first connect); the option `realtime` of `createClient` can fix it instead.
- `db.presence(table)` lists who is looking at a table now: one entry per user connected to its live lane (people who may read it). Realtime tier only (`GET /v1/rt/presence`).
- `db.publish(table, data)` sends an ephemeral message - typing, a cursor, "someone is editing" - to everyone on the table's live lane; `db.onMessage(table, cb)` receives them (this client's own too) and returns the function that stops it. Messages are never stored and never change data. Only people who may read the table get them. Realtime tier only: on a server without it, `publish` rejects with `code: "unsupported"`.

```js
const stop = db.onMessage("tasks", (data, from) => showTyping(from?.name, data.taskId));
input.addEventListener("input", () => db.publish("tasks", { typing: true, taskId }));
console.log((await db.presence("tasks")).map((p) => p.name ?? p.id));
```

## status and close

```ts
readonly status: ConnectionStatus
close(): void
```

`db.status` is the socket's current status. `db.close()` closes the socket and every subscription, cancels any pending reconnect and sets the status to `"closed"`. Subscribing again later opens a new socket.

## Errors

Every failed call rejects with a `DabadataError`, which extends `Error`:

| Field | Type | Meaning |
| --- | --- | --- |
| `name` | `"DabadataError"` |   |
| `message` | `string` | The explanation if there is one, else the server's message(s), else the code. |
| `status` | `number` | HTTP status; `0` for network and socket errors. |
| `code` | `string?` | The body's `error`: `"forbidden"`, `"unauthorized"`, `"validation"`, `"not found"`, and on hosted projects `"plan_limit"`, `"rate_limited"`, `"project_deleted"`, ... |
| `explanation` | `string?` | The rule engine's sentence, on 403s and refused subscriptions. |
| `traceId` | `number \| string?` | The call's row in the trace. |
| `body` | `unknown` | The parsed response body, for anything else you need. |

```js
try {
  await db.delete("tasks", id);
} catch (err) {
  if (err instanceof DabadataError && err.code === "forbidden") {
    showToast(err.explanation); // delete tasks: denied, role "viewer" not in ["admin"]
  } else {
    throw err;
  }
}
```

## Connection status

| Status | Meaning |
| --- | --- |
| `"closed"` | No socket: before the first subscribe, or after `close()`. |
| `"connecting"` | The first connection is being opened. |
| `"open"` | Connected; subscriptions are live. |
| `"reconnecting"` | The connection dropped; waiting for the next attempt or making it. |

The full reconnect behaviour - back-off, re-subscribing, dead-link detection - is on [Live subscriptions](https://dabadata.com/docs/live/index.md#reconnect).

## Typed tables

Generate TypeScript types from your definition and pass them to `createClient`: a wrong table name, a misspelled column or a value of the wrong type becomes a compile error in your editor instead of a 400 from the server, and rows come back typed.

```bash
dabadata types                    # -> dabadata-types.ts, for the environment you use
# or, with any key and token of the environment (not anonymous):
curl -o src/dabadata-types.ts https://dabadata.com/v1/schema/types -H "Authorization: Bearer $DABADATA_SECRET_KEY"
```

```ts
import { createClient } from "./lib/dabadata.js";
import type { Database } from "./dabadata-types.js";

const db = createClient<Database>({ url: "https://dabadata.com", key: "pk_live_..." });

const task = await db.insert("tasks", { title: "Buy milk" });   // task.title: string, task.done: boolean
await db.update("tasks", task.id, { done: true });
const open = await db.query("tasks", { where: { done: false }, orderBy: ["-created_at"] });
await db.action("rsvp", { event: "evt_1" });

db.insert("taks", { title: "x" });     // error: "taks" is not a table
db.insert("tasks", { titel: "x" });    // error: did you mean "title"?
db.insert("tasks", { title: 1 });      // error: title is a string
```

- The file has a `row`, an `insert` and an `update` shape per table, the rows and params of your views, and the params of your actions. Required columns are required in `insert`; optional ones, columns with a default and `userId` columns are not. A column with a column read rule may be absent from a row (`?`).
- Types follow the column types: text, ids, files, dates and datetimes are strings; `int` and `float` numbers; `json` is `Json`; a location is `{ lat, lng }`.
- Typed `where` keys must be columns of the row; `orderBy` takes a column, `"-column"` or a list of them (not a comma string).
- Run `dabadata types` again after changing the definition: an outdated file shows up as compile errors, which is the point. Without `<Database>` the client is exactly as before.
- The server still checks everything; the types only move the mistakes earlier. Swift, Kotlin and Dart types will come from the same definition when those SDKs exist.

## Types

Every type is in [`/client/dist/dabadata.d.ts`](https://dabadata.com/client/dist/dabadata.d.ts), served next to the bundle. In a TypeScript project without a checkout of dabadata, save both files side by side (for example `src/lib/dabadata.js` and `src/lib/dabadata.d.ts`) and import `./lib/dabadata.js`; TypeScript reads the types from the file next to it:

```bash
curl -o src/lib/dabadata.js   https://dabadata.com/client/dist/dabadata.js
curl -o src/lib/dabadata.d.ts https://dabadata.com/client/dist/dabadata.d.ts
```

```ts
import { createClient, DabadataError, type AuthUser, type Row } from "./lib/dabadata.js";
```

The main ones:

```ts
type Row = {
  id: string;
  created_at?: string;
  updated_at?: string;
  created_by?: string;
  [column: string]: unknown;
};

type Where = Record<string, unknown>;

interface QueryOptions { where?: Where; orderBy?: string; desc?: boolean; limit?: number }
interface SubscribeOptions { where?: Where }
interface ViewOptions { params?: Record<string, unknown>; where?: Where; orderBy?: string | string[]; desc?: boolean; limit?: number }
interface ViewTarget { view: string; params?: Record<string, unknown> }

type ConnectionStatus = "connecting" | "open" | "reconnecting" | "closed";

interface Change<R = Row> { op: "insert" | "update" | "delete"; row: R }

interface SubscribeHandlers<R = Row> {
  onRows?(rows: R[]): void;
  onChange?(change: Change<R>): void;
  onError?(error: DabadataError): void;
  onStatus?(status: ConnectionStatus): void;
}

interface Subscription { readonly id: string; readonly table: string; readonly view?: string; close(): void }

interface TokenSet { token: string; expiresAt: string; refreshToken: string; refreshExpiresAt: string }

type AuthStorage = "local" | "memory";

interface AuthUser { id: string; email: string; name: string | null; role: string; emailVerified: boolean; hasPassword: boolean; createdAt: string }

type AuthChangeEvent = "signIn" | "signOut" | "refresh" | "update";

interface ManagedUser extends AuthUser { disabled: boolean; lastSignInAt: string | null; hasPassword: boolean; providers: AuthProvider[] }

interface UserList { users: ManagedUser[]; next: string | null }

interface SignUpResult { user: AuthUser | null; verify: boolean; signupSecret: string | null }

type AuthProvider = "google" | "apple" | "microsoft" | "github" | "gitlab" | "discord" | "linkedin" | "slack";

type IdTokenProvider = "google" | "apple";

interface AuthProviders { providers: AuthProvider[]; page: string }

interface AuthApi {
  signUp(input: { email: string; password: string; name?: string; redirectUrl?: string }): Promise<SignUpResult>;
  signIn(input: { email: string; password: string }): Promise<AuthUser>;
  signInWithLink(input: { email: string; redirectUrl?: string }): Promise<void>;
  providers(): Promise<AuthProviders>;
  signInWithProvider(provider: AuthProvider, input?: { redirectUrl?: string }): Promise<void>;
  signInWithIdToken(input: { provider: IdTokenProvider; idToken: string; nonce?: string; name?: string }): Promise<AuthUser>;
  completeLink(input?: { url?: string; password?: string; signupSecret?: string }): Promise<AuthUser | null>;
  resetPassword(input: { email: string; redirectUrl?: string }): Promise<void>;
  signOut(input?: { everywhere?: boolean }): Promise<void>;
  user(input?: { force?: boolean }): Promise<AuthUser | null>;
  onAuthChange(cb: (user: AuthUser | null, event: AuthChangeEvent) => void): () => void;
  update(input: { name?: string | null; password?: string; currentPassword?: string }): Promise<AuthUser>;
  invite(input: { email: string; name?: string; role?: string; redirectUrl?: string }): Promise<void>;
  invites(): Promise<Invite[]>;
  cancelInvite(id: string): Promise<void>;
  readonly admin: {
    listUsers(input?: { query?: string; limit?: number; after?: string }): Promise<UserList>;
    setRole(id: string, role: string): Promise<ManagedUser>;
    setName(id: string, name: string | null): Promise<ManagedUser>;
    disable(id: string): Promise<ManagedUser>;
    enable(id: string): Promise<ManagedUser>;
    remove(id: string): Promise<void>;
  };
}

type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
```

## Versions and changes

Each released version is served at `https://dabadata.com/client/v<version>/` - `dabadata.js`, `dabadata.iife.js` and `dabadata.d.ts` - with `Cache-Control: public, max-age=31536000, immutable`. A released file never changes and is never removed; a fix is a new version. `/client/dist/` follows the newest version and changes with it.

- **Version numbers.** While the client is `0.x`, the last number goes up for fixes and additions (`0.2.0` -> `0.2.1`: safe to take), and the middle one for a change that can break a working app (`0.2.x` -> `0.3.0`: read its entry first).
- **The API stays compatible with released clients.** Server changes add fields and routes; an older pinned client keeps working against the current API. If a change ever has to break an older client, its entry here says so and names the first version that works.
- **Pinning and testing.** Load a pinned URL, run your tests against it, then change the version number in one place to upgrade. To vendor the files instead, `curl` them from the pinned folder.

### 0.2.0 - 19 October 2026

The first pinned release: the client as documented on this page.

- **New:** `db.storage.bindImage` and `db.storage.watchUrl` keep private files showing on pages that stay open longer than a signed URL lives ([Private files on pages that stay open](https://dabadata.com/docs/storage/index.md#renew)).
- **New:** `db.storage.signedUrl` returns `expiresIn` (seconds) when the server sends it.
- **New:** `createClient<Database>()` with the types `dabadata types` writes from your definition ([Typed tables](https://dabadata.com/docs/client/index.md#typed-tables)).
- Breaking changes: none.
