TotalBase
Data API

A client, end to end

A small TypeScript client for the Data API: sign in, list with filters and relations, page, create, update safely, and handle errors.

The other Data API pages cover one topic each. This one puts them together: a client for Acme's work orders, in TypeScript with plain fetch and no dependencies. Every request it makes is one of those pages' examples, so each step links to the details.

1. Sign in, or use a token

A person signs in with their email and password and gets a session token. A program uses a personal access token (tb_…) instead, created in API tokens. Both go in the same header. See Authentication.

const BASE = 'https://ops.acme-field.example'

async function signIn(email: string, password: string): Promise<string> {
  const res = await fetch(`${BASE}/api/auth/login`, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ email, password }), // auth routes take their fields directly, not in `data`
  })
  if (!res.ok) throw new Error('Invalid email or password')
  return (await res.json()).data.token
}

2. One function for every call

Every route answers { data, meta? }, or { errors: [...] } on a failure. One helper unwraps both, so the rest of the client never parses responses. See Request and response.

interface ApiError { code: string; message: string; field?: string; source: 'TOTALBASE' | 'POSTGRESQL' }

class DataApiError extends Error {
  constructor(public status: number, public errors: ApiError[]) { super(errors[0]?.message ?? `HTTP ${status}`) }
  get code() { return this.errors[0]?.code }
  /** The column an error is about - show it beside that field. */
  get field() { return this.errors[0]?.field }
}

async function api<T>(token: string, path: string, init: RequestInit = {}): Promise<{ data: T; meta?: Record<string, unknown>; etag?: string }> {
  const res = await fetch(`${BASE}${path}`, {
    ...init,
    headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', ...init.headers },
  })
  if (res.status === 204) return { data: undefined as T }
  const body = await res.json()
  if (!res.ok) throw new DataApiError(res.status, body.errors)
  return { data: body.data, meta: body.meta, etag: res.headers.get('etag') ?? undefined }
}

3. List with a filter, a relation and a page

fields chooses columns and follows foreign keys with a dot (site.name reads the site's name in the same request). where filters, order_by sorts, limit and offset page, and count=true adds the total. See Reading and Filtering.

interface Order { id: string; number: string; status: string; priority: string; 'site.name': string; due_at: string }

async function openOrders(token: string, page = 0, pageSize = 20) {
  const query = new URLSearchParams({
    fields: 'id,number,status,priority,site.name,due_at',
    'where[status][_in]': 'NEW,ASSIGNED,IN_PROGRESS',
    order_by: 'due_at',
    limit: String(pageSize),
    offset: String(page * pageSize),
    count: 'true',
  })
  const { data, meta } = await api<Order[]>(token, `/api/public/table/work_orders?${query}`)
  return { orders: data, total: meta?.count as number }
}

Your roles decide what comes back. A row filter narrows the rows (a technician sees only their orders); a column without a field grant makes the request 403 FIELD_PERMISSION_DENIED, so ask only for columns your role may read. Docs › OpenAPI lists exactly those, for your roles.

4. Create

POST with { "data": {...} } answers 201 with the row as PostgreSQL stored it, so defaults and values set by triggers (number, due_at) come back too. See Writing.

async function reportOrder(token: string, site: string, branch: string, description: string) {
  const { data } = await api<Order>(token, '/api/public/table/work_orders', {
    method: 'POST',
    body: JSON.stringify({ data: { type: 'REPAIR', priority: 'NORMAL', site, branch, description } }),
  })
  return data // data.number is "WO-000413", set by the database
}

To create a row with its children in one request (an order with its parts), see Nested writes. For several tables in one transaction, see Transactions.

5. Update without overwriting someone else's change

Read the row by key: its ETag is the row's version. Send it back in If-Match, and if someone changed the row in between, the update answers 409 ROW_VERSION_CONFLICT instead of overwriting it. See Optimistic writes.

async function assign(token: string, id: string, technician: string) {
  const { etag } = await api<Order>(token, `/api/public/table/work_orders/${id}`)
  const { data } = await api<Order>(token, `/api/public/table/work_orders/${id}`, {
    method: 'PATCH',
    headers: { 'if-match': etag! },
    body: JSON.stringify({ data: { status: 'ASSIGNED', technician } }),
  })
  return data
}

6. Handle errors by their code

Branch on code, never on the message: codes are stable. The message is written for a person. A constraint's message is its author's own words, and field names the column, so a form can show it beside the field. See Errors.

try {
  await assign(token, id, technician)
} catch (error) {
  if (!(error instanceof DataApiError)) throw error
  switch (error.code) {
    case 'ROW_VERSION_CONFLICT': /* reload the row and show the newer values */ break
    case 'CHECK_VIOLATION':
    case 'NOT_NULL_VIOLATION':
    case 'UNIQUE_VIOLATION': /* show error.message under error.field */ break
    case 'ACTION_DENIED': /* "No UPDATE grant on public.work_orders for your roles." - hide the action */ break
    case 'AUTHENTICATION_REQUIRED':
    case 'SESSION_EXPIRED': /* sign in again */ break
    case 'RATE_LIMIT_EXCEEDED': /* wait the Retry-After header's seconds */ break
    default: throw error
  }
}

7. Types from the OpenAPI document

GET /api/openapi.json describes every table, view and function your roles reach, with their columns and types. A generator turns it into TypeScript types, so Order above comes from the database instead of being written by hand:

curl -s https://ops.acme-field.example/api/openapi.json -H "authorization: Bearer $TOKEN" > openapi.json
npx openapi-typescript openapi.json -o data-api.d.ts

The document follows your permissions and changes when the schema or a grant changes, so regenerate it after a change. See OpenAPI.

Next steps