dabadataDocs
Reference / Wire protocol

Reference

Wire protocol

Everything a client library needs to speak to dabadata, written so a Dart, Swift or Kotlin SDK can be built without reading the JavaScript client: the HTTP basics, credentials and how a call is placed, token refresh with exact timings, every data route, the /v1/live socket, the Centrifugo transport with recovery, the end-user sign-in routes, the error bodies, and the list of rules a conforming SDK follows. The conformance suite checks an SDK against all of it.

HTTP basics

  • Base URL. https://dabadata.com for hosted projects, or the URL of a self-hosted server. Every API route starts with /v1/.
  • Bodies. Requests with a body send Content-Type: application/json (UTF-8). Every answer is JSON (application/json; charset=utf-8) except the export, which is NDJSON.
  • Discovery. GET /v1/meta (no credential) answers { "name", "contract": "2026-10-15", "realtime": "inproc" | "centrifugo", "realtimeUrl"?, "hosted" }. Ask it once, before the first socket: realtime names the live transport (inproc or centrifugo). A network failure means "ask again next time"; any HTTP answer is final.
  • Times are ISO 8601 strings in UTC (2026-10-15T09:30:00.000Z), dates YYYY-MM-DD.
  • CORS. Browsers may call from any origin unless the environment lists its origins; servers and mobile apps send no Origin and are never blocked by it.
  • Health. GET /v1/health answers { "ok": true }.

Every route, with its full request and answer, is on the HTTP API page; this page covers what an SDK uses and the rules around it.

Credentials and placement

A project has two environments, live and test, each with its own data, keys and tokens. Every call is placed in exactly one environment by the credentials it carries, never by anything in the URL or the body.

CredentialLooks likeSent asHeld by
Publishable keypk_live_ / pk_test_ + 24 charactersheader X-Api-Key; ?key= on socketsthe app (it is public)
Access token64 hex characters, lives 1 hourAuthorization: Bearer; ?token= on socketsthe signed-in end user
Refresh token64 hex characters, lives 30 days (sliding)only in the body of POST /v1/users/refreshthe end user's device
Provider JWTthree base64url parts (xxx.yyy.zzz)like an access tokenapps that bring their own identity provider
Secret keysk_live_ / sk_test_ + 40 charactersAuthorization: Bearerservers only: it bypasses every rule

The server places a call in this order:

  1. Authorization: Bearer sk_...: that key's environment, as the service identity (rules bypassed).
  2. On a self-hosted server, a Bearer equal to the server's project key: the default project, as service.
  3. A publishable key (X-Api-Key or ?key=): that key's environment. A token sent with it (Bearer or ?token=) must belong to the same environment; no token means the anonymous caller.
  4. No key: an access token places the call in the environment it was minted for. A provider JWT, or no credential at all, goes to the server's default project (self-hosted); a server without one answers 401.

A credential of one environment sent with the key of another is a 401. So is an unknown, expired or revoked key, anything in X-Api-Key that is not a valid publishable key, and a publishable key sent as Bearer. A project being deleted answers 410 project_deleted (after the credentials were checked); a paused one wakes up on the call.

Errors

Every error answer is a JSON object with a string error. The other fields depend on the error:

StatuserrorAlsoWhen
400validationmessages: string[] (every problem at once); index in a transactiona bad body, value, filter or definition
401unauthorized-no credential where one is needed, or a bad, expired or revoked one
403forbiddenexplanation (the rule's decision, as text), traceId; index in a transactiona rule denied the call
403plan_limitlimit, messageover the plan (connections, storage, egress)
404not foundmessage, traceId (data routes)an unknown route, table or row
405method not allowed-a known route, another method
409conflictmessage, traceId, and one of row (expect / ifAbsent), column, columns, check, constraint, ref (a ref column's onDelete); index and retryable in a transactiona write that would break an expect, ifAbsent, unique column or check
410project_deleted-the project is being deleted
429rate_limitedretryAfter (seconds), and the Retry-After headertoo many calls
500internal error-a server fault
503realtime_unavailable, ...-a backing service is down

An SDK error object carries the HTTP status (0 for a network failure), the error code, the explanation, the traceId and the whole body. Its message is the explanation, else message, else the validation messages joined with "; ", else the code. Show developers the explanation: it names the rule path that decided, e.g. update tasks: denied, any[0] role "viewer" not in ["admin"]; any[1] row.owner "bob" != user "carol".

Tokens and refresh

End users get a pair: an access token valid 1 hour and a refresh token valid 30 days. Pairs come from POST /v1/users/token (your server, with the secret key: { id, name?, role }), POST /v1/users/exchange ({ jwt }: a provider token traded for a pair), the hosted sign-in routes (below), and refreshes. Every pair has the same shape:

json
{ "token": "9f2c...", "expiresAt": "2026-10-15T10:30:00.000Z",
  "refreshToken": "41ab...", "refreshExpiresAt": "2026-11-14T09:30:00.000Z" }

POST /v1/users/refresh with { "refreshToken": "..." } (no Authorization header; send the publishable key, which must be of the token's environment) answers a new pair. The used refresh token is deleted and the new one is valid 30 days from now, so a user who comes back within 30 days stays signed in. A refresh token works once, even under a race: two calls with one token get one pair and one 401. Access tokens already handed out keep working until their own expiry. An unknown, expired, used or revoked refresh token is 401; a body without one is 400. POST /v1/users/revoke (secret key, { id }) deletes every token and refresh token of a user: their next call is 401 and so is their next refresh.

The rules an SDK follows, with these exact timings:

  1. Ahead of time. Schedule a refresh 60 seconds before expiresAt, never sooner than 5 seconds from when it is scheduled.
  2. On 401. When any HTTP call answers 401 and the SDK holds a refresh token, refresh once and retry that call once. If the refresh fails, the caller gets the original 401. If another call already refreshed while this one was in flight, just retry.
  3. One at a time. Concurrent refreshes share one request (a second refresh would present a refresh token the first one already used).
  4. Dead token. A refresh answered 401 means the refresh token is gone for good: drop it; the user is signed out.
  5. Persist every pair. Hand each new pair to the app (or the SDK's own storage); the previous refresh token no longer works.
  6. Sockets. A socket's identity is fixed when it opens; it stays open after its access token expires. Every new connection, a reconnect included, needs a valid token: when a socket closes before it opened while the access token is expired (or of unknown expiry), refresh once per streak of failed connects, then reconnect. An open socket is kept across a refresh; the next reconnect uses the new token.

Hosted projects limit the token routes: 60 calls a minute per environment for /v1/users/token, /exchange and /refresh together, and 30 a minute per IP address for refresh and exchange.

Rows and types

A row is a JSON object with the table's declared columns plus id (text, a UUID), created_at and updated_at (datetime), created_by (the id of the user who inserted it, service for the secret key) and _seq (a number that grows with every write in the environment). Clients never set these; sending one is a 400.

Column typeJSON
text, userId, refstring
intnumber (a safe integer)
floatnumber
booltrue / false
date"YYYY-MM-DD"
datetimeISO 8601 string
jsonany JSON value
location{ "lat": 52.52, "lng": 13.405 }

An empty optional column is null. A column the caller may not read (a column read rule) is absent from the row - the key is missing, not null - on every route and socket; an SDK must not invent it. The definition of the environment (tables, columns, rules, views) is GET /v1/schema for any signed-in user; end users see the open tables and their own shape of each view.

Reading

GET /v1/db/:table answers { "rows", "traceId", "cursor" }: the rows the caller's read rule lets them see. Parameters:

  • where: a JSON object (URL-encoded). Keys are columns (declared ones, plus id, created_by, created_at, updated_at). A value is equality (null = is null) or an object of operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $like, $ilike, $null, and on json columns $contains (Postgres containment: an array holding a value, an object holding a sub-object); $and and $or take lists of filters. An unknown column or a value of the wrong type is a 400. A location column is filtered with "$near": { "column", "lat", "lng", "km" } (top level); every row then carries _distance in km.
  • orderBy: a column (default created_at, newest first), or up to 5 separated by commas, each with an optional - for descending (orderBy=due,-created_at); a single column is ascending unless desc=1. With a $near, orderBy=_distance sorts nearest first.
  • limit: 1 to 1,000, default 100.
  • since: a _seq cursor; see catching up.

A list is not a 403 for a table the caller's read rule shuts out: it answers "rows": []. The exception is a strict table (access: "views"): every /v1/db route answers end users 403 read <table>: denied, <table> is read through views only (access: views). GET /v1/db/:table/:id answers { "row", "traceId" }, 404 when there is no such row, 403 when it exists but the read rule hides it. An unknown table is 404. A where or orderBy on a column the caller may not read is a 400 (where: column "answer" is not readable by this caller).

Views. GET /v1/views/:name?params=&where=&orderBy=&desc=&limit= (or POST with the same fields as a body) answers { "rows", "traceId", "params" }; GET /v1/views/:name/:id?params= answers { "row", "traceId" }. Rows carry the view's columns, its computed fields and _seq. Errors: 404 unknown view, 403 forbidden from its allow, 400 params (every message in messages), 400 a where / orderBy outside the view's filter / sort. See Views; SDKs offer it as view(name, options) and viewRow(name, id, options).

Writing

POST /v1/mutate with { "table", "op": "insert" | "update" | "delete", "id"?, "values"?, "expect"?, "ifAbsent"? } answers { "ok": true, "row", "traceId" }; a delete answers the deleted row. row is the full row only when the caller's read rule lets them read it (after the write; for a delete, before it) - otherwise it is { "id" } - and leaves out columns the caller may not read. The same holds for every results[i].row of /v1/transact.

  • insert takes values: unknown columns, missing required columns and wrong types are a 400 listing every problem. Columns with a default get it; every missing userId column is filled with the caller's id. Insert takes plain values only ({ "$set": v } is unwrapped; any other operator is a 400).
  • update takes id and values; a value may be an atomic operator computed by the server: { "$inc": n }, { "$dec": n } (int, float; null counts as 0), { "$min": v }, { "$max": v } (int, float, date, datetime), { "$append": v }, { "$remove": v } (json arrays), { "$set": v } (a literal that would otherwise look like an operator). A value is an operator when it is an object with exactly one key starting with $.
  • delete takes id.
  • expect (update, delete): column values that must hold on the row at write time, as literals (equality) or comparisons { "$eq" | "$ne" | "$gt" | "$gte" | "$lt" | "$lte": v }. When they do not hold, nothing changes and the answer is 409 conflict with the current row (left out when the caller may not read it).
  • ifAbsent (insert): values that no existing row may have; one that does is a 409 with that row.

The order on update and delete is: the row is locked, then 404, then the rule (403), then expect (409), then the write. Insert: the rule on the values, then ifAbsent, then the insert. A value that breaks a unique column is 409 with column; one that breaks a check, 409 with check (its name). A column may carry its own update rule and ops (the operators it takes); an update must pass the rule of every column it writes, and the 403 names the column. A delete applies the onDelete of ref columns pointing at the row in the same transaction: restrict (409 with ref), cascade, setNull.

http
POST /v1/mutate
X-Api-Key: pk_test_...
Authorization: Bearer 9f2c...
Content-Type: application/json

{ "table": "products", "op": "update", "id": "6c1d...",
  "values": { "stock": { "$dec": 1 } }, "expect": { "stock": { "$gte": 1 } } }

Transactions

POST /v1/transact with { "ops": [ ... ] } (1 to 50 ops) runs every op in one serializable Postgres transaction: all or nothing. Each op is { "table", "op": "insert" | "update" | "delete" | "check", "id"?, "values"?, "expect"?, "ifAbsent"?, "ref"? }, checked against its table's rules exactly like a single write. A check op only asserts expect on a row. An insert may name itself with "ref": "x"; later ops refer to its row with { "$ref": "x" } as id or as a whole column value.

The answer is { "ok": true, "results": [ { "row" }, ... ], "traceIds": [ ... ] }, one entry per op. A failing op fails the whole transaction with its own status and body plus index (its position); nothing is written. When Postgres keeps reporting a serialization conflict after 5 attempts the answer is 409 { "error": "conflict", "retryable": true }: call again. Live subscribers see the changes only after the commit.

Actions

POST /v1/actions/<name> with { "params": { ... } } runs an action of the definition: its steps (transaction ops with placeholders) in one transaction with the server's rights, when the caller passes the action's allow rule (else 403 with the explanation). The answer is { "ok": true, "results": [ { "row" }, ... ], "traceId", "traceIds" }, a row being null when the caller's read rule hides it. Bad params are a 400 listing every problem; a failing step answers like a failing transaction op, with index = the step. SDKs offer it as action(name, params). An action with an ai block adds ai to the answer ({ status: "done", text, stopReason, usage, traceId }, { status: "failed", error, traceId } or { status: "queued", id }), may answer 429 rate_limited / ai_busy with Retry-After, and 502 ai_failed when it has no steps and the model call failed.

The /v1/live socket

When GET /v1/meta says "realtime": "inproc" (self-hosted servers), live data comes over one WebSocket per client:

text
wss://<host>/v1/live?key=pk_test_...&token=<access token>

Leave out token for an anonymous socket. A bad or expired token fails the upgrade with 401 (a browser sees the socket close before it opens); an origin outside the environment's list fails with 403 origin_not_allowed; a workspace at its connection allowance with 403 plan_limit. Messages are JSON text frames:

DirectionMessageMeaning
server{ "type": "hello", "project", "projectId", "env" }first message after the upgrade
client{ "type": "subscribe", "id": "s1", "table": "tasks", "where"?: {...} }id is the client's, unique on the socket
client{ "type": "subscribe", "id": "s2", "view": "open_questions", "params"?: {...}, "where"?: {...} }a view; exactly one of table / view
server{ "type": "rows", "id": "s1", "rows": [...], "reason"? }the full result (read rule and where applied, at most 1,000 rows); sent again at any time as a reset, with reason "definition", "time", "overflow", "relation" or "join"
server{ "type": "change", "id": "s1", "op": "insert" | "update" | "delete", "row" }one per change the subscriber may see; row of a delete is { "id" }
server{ "type": "error", "id": "s1", "error": "forbidden", "explanation" }the subscription was refused (or error is a message, e.g. an unknown table)
client{ "type": "unsubscribe", "id": "s1" }no more messages for s1
client / server{ "type": "ping" } / { "type": "pong" }keep-alive

Changes made while the first rows load are held and sent right after rows, so there is no gap. For each change the server decides per subscriber, with the subscriber's read rule and where: an insert whose row passes is an insert; an update whose new row passes is an update (also when the subscriber did not have the row before, e.g. it was just handed to them: treat an update of an unknown row as an insert); an update whose old row passed but whose new row does not is a delete; a delete whose row passed is a delete. A subscription to a table the caller may not read at all is refused with the rule's explanation. Other message types (trace, event, api_log, ...) go to operator sockets: ignore them.

Changes are shaped per subscriber: columns they may not read are left out, an update that changes nothing they can see is not sent, and an update carries the whole visible row (replace the row, do not merge). Rows may enter or leave by time with no write (a rule on $now): they arrive as update and delete within about a second. A rows message for an open subscription replaces what the SDK holds for it, exactly as after a reconnect.

The server may close the socket with code 4001 (project paused or deleted). After any close the SDK reconnects with backoff (1, 2, 4, 8, then every 10 seconds; reset after a successful open), sends every subscription again, and treats the new rows as a reset of that subscription's data. Send a ping every 25 seconds so proxies keep the connection.

The realtime tier (Centrifugo)

Hosted servers answer "realtime": "centrifugo" and a realtimeUrl. Live data then comes from Centrifugo v6 over one WebSocket, in its JSON protocol; no Centrifugo SDK is needed. The server decides which channels a connection hears; the client never subscribes to channels itself.

Connection token

GET /v1/rt/token with the same credentials as any HTTP call (publishable key plus the user's token, or the key alone for anonymous) answers { "token", "url", "expiresAt", "channels": [...] }. The token is a JWT valid 15 minutes; channels is its claim. A 401 refreshes the user's token once, like any call. A workspace at its connection allowance gets 403 plan_limit.

ChannelCarries
env:<env>nothing (presence only: connection counts)
tbl:<env>.<table>every change of a table the user may read entirely, every column, now and later
tbl:<env>.<table>~<role>the changes that role sees, shaped once per role, when the read depends on the role, row values or time but not on who the user is (anonymous callers always)
tbl:<env>.<view>~<role>a view's changes for that role (publications carry view)
usr:<env>#<sub>changes this user may see when the read depends on who they are (owner, $user.id, a demo room), shaped for them, and resync
live:<env>.<table>ephemeral messages between clients; never data

<env> is prj_x, prj_x_test, or default on a self-hosted default project. A table with no live: channel in the claim is one the caller may not read (a strict table included): report it to that subscription as a 403. The token answer also carries views: { "<name>": { "channel" } | { "error" } }: the lane of each view whose allow passes, or why the view has none - a view whose params do not bind by equality (<column> = $params.<name>) is served over HTTP only, and its live subscription reports that error.

Connect, pushes, pings

Open url, then send the connect command. Centrifugo may put several JSON objects in one frame, one per line: split on newlines.

json
{ "id": 1, "connect": { "token": "eyJ...", "name": "my-sdk",
    "subs": { "tbl:prj_x_test.tasks": { "recover": true, "offset": 42, "epoch": "Kx9q" } } } }

{ "id": 1, "connect": { "client": "...", "ping": 25, "pong": true, "expires": true, "ttl": 900,
    "subs": { "tbl:prj_x_test.tasks": { "recoverable": true, "epoch": "Kx9q", "offset": 45,
              "recovered": true, "publications": [ { "data": {...}, "offset": 43 }, ... ] } } } }

subs on connect lists every data channel (tbl:, usr:) the SDK holds a position (offset, epoch) in; leave it out on the first connect. Remember each data channel's epoch and offset from the reply and from every publication. Publications arrive as pushes:

json
{ "push": { "channel": "tbl:prj_x_test.tasks",
            "pub": { "offset": 46, "data": { "type": "change", "table": "tasks", "op": "update",
                                               "row": { "id": "6c1d...", "title": "Ship it", "_seq": 1890 }, "seq": 1890 } } } }

The data lanes carry { "type": "change", "table", "op", "row", "seq" } (a delete's row is { "id" } and its seq the tombstone's; a view lane's carries view instead of table, plus bind: the row's values of the columns its params bind) and { "type": "resync", "table": "tasks" | null } (or "view"): drop that table's or view's rows (or all) and load again. A change with "reshape": true is the same row at a later instant (a time boundary passed, on ~<role> and usr: lanes): apply it when its seq is at least the row's known _seq. An empty object {} from the server is a ping: answer {} when the connect reply said "pong": true. When nothing at all arrived for the ping interval plus 10 seconds, close and reconnect.

Renew the token 60 seconds before the connection's ttl ends: GET /v1/rt/token?refresh=1, then send { "id": n, "refresh": { "token": "..." } }. If the renewal is refused (the user signed out) Centrifugo closes the connection when the token expires. The server disconnects with code 4100 (resync: the definition or the user's role changed) and 4403 (revoked); after 4100 reconnect and reload every subscription.

Applying changes

  1. Load over HTTP. A subscription to table with where loads GET /v1/db/<table>?limit=1000&where=... after the connection is up and keeps its cursor. Pushes for that table that arrive during the load are applied after it.
  2. At most once per row. Track, per row id, the highest seq applied or deleted. A change whose seq is not above it is a duplicate (recovery and catch-up may repeat changes): drop it.
  3. Filter. Apply changes of the subscription's table whose row matches its where (for a view: whose bind values equal the subscription's params - a null on either side never matches); an update whose row no longer matches is a delete; an update of a row not held is an insert. An update replaces the row; it is never merged.
  4. Never apply live:. Anything on a live: channel is an ephemeral message from another client (typing, cursors): hand it to the app, never to the data.

Recovery and catching up

On reconnect, send each data channel's position with recover: true. For a channel whose reply says recovered: true, the missed publications come in publications (and are applied like pushes). For a channel that did not recover (history is kept 10 minutes, 200 publications), or that is new in the claim, every subscription of the tables it carries catches up over HTTP:

http
GET /v1/db/tasks?since=1840&where=...&limit=1000

{ "rows": [ ...changed rows the caller may read, in _seq order... ],
  "deleted": [ { "id": "6c1d...", "_seq": 1871 } ], "cursor": 1890, "more": false, "traceId": 77 }

Apply rows as inserts (unknown id) or updates (known id) and deleted as deletes of rows held, keep the new cursor, and ask again while more is true. A caller whose read of the table depends on time gets { "reset": true, "cursor", "traceId" } with no rows instead (time moves rows without a new _seq): load the subscription again. Views answer no ?since=: a view lane that lost history reloads the view over HTTP. A cursor older than 6 days reloads the whole subscription instead (deletions are remembered 7 days). While a catch-up or reload runs the connection status is resyncing.

Presence: GET /v1/rt/presence?table= answers who is connected to a table's live lane, { "presence": [ { "id", "name"?, "role" } ] }. Publishing an ephemeral message: { "id": n, "publish": { "channel": "live:<env>.<table>", "data": {...} } } (signed-in users only).

End-user sign-in routes

When a project hosts its users' accounts (auth.users.enabled), these routes take the publishable key and nothing else (404 when the project does not host accounts). tokens is a pair; user is { id, email, name, role, emailVerified, hasPassword, createdAt }.

RouteBodyAnswer
POST /v1/auth/users/signup{ email, password, name?, redirectUrl? }{ ok, verify: true, signupSecret } (a link was mailed), or { ok, verify: false, tokens, user }; 403 signup_closed
POST /v1/auth/users/verify{ token, signupSecret? }{ tokens, user, redirectUrl }
POST /v1/auth/users/signin{ email, password }{ tokens, user }; 401 invalid_login, 403 email_unverified / user_disabled
POST /v1/auth/users/magic{ email, redirectUrl? }{ ok: true } (a sign-in link is mailed)
POST /v1/auth/users/magic/verify{ token }{ tokens, user, redirectUrl }
POST /v1/auth/users/forgot{ email, redirectUrl? }{ ok: true }
POST /v1/auth/users/reset{ token, password }{ tokens, user, redirectUrl }
GET /v1/auth/users/me(the user's token){ user }
PATCH /v1/auth/users/me{ name?, password?, currentPassword? }{ user }, plus a new tokens after a password change
POST /v1/auth/users/signout{ refreshToken? } or { everywhere: true }{ ok, revoked: { tokens, refreshTokens } }

Links in mails go to the project's hosted pages, which send the person back to redirectUrl with the pair in the URL fragment (#token=...&refreshToken=...&expiresAt=...&refreshExpiresAt=...). An app that handles links itself asks for redirectUrl?mode=app and gets #token=<link token>&type=verify|magic|reset|invite; it posts the token to the matching route (reset and invite with a new password). Keep the signupSecret of a sign-up for 24 hours and send it with that sign-up's verify link: only then do the password and name given at sign-up survive. Passwords are 10 to 200 characters. Details on Sign-in for your app's users.

What a conforming SDK does

Must

  1. Send the publishable key as X-Api-Key on every HTTP call and as ?key= on sockets (hosted projects); never take or embed a secret key in a device app.
  2. Send the user's access token as Authorization: Bearer (HTTP) and ?token= (sockets) when signed in, and nothing when anonymous.
  3. Turn every non-2xx answer into an error with the status, error, explanation, traceId and the body; a network failure is status 0.
  4. Follow the six refresh rules: 60 s ahead (never sooner than 5 s), one refresh and one retry on 401, one refresh at a time, drop a refresh token answered 401, hand every new pair to the app, refresh once per failed socket connect streak when the token is expired.
  5. Pick the live transport from GET /v1/meta and speak both: /v1/live and Centrifugo.
  6. On /v1/live: send every subscription again after a reconnect and treat every rows as a reset, whenever it arrives; apply change messages (an update of an unknown row is an insert and replaces a known one; a delete carries only id); ignore unknown message types.
  7. On Centrifugo: recover with offsets and epochs; apply each row change at most once by seq (a reshape change at an equal seq too); catch up with ?since= when history is lost, and load again on reset: true; reload on resync (also when it arrives while that subscription loads: load once more after it) and on close code 4100; renew the connection token 60 s before it expires; answer pings; never apply live: messages as data.
  8. Reconnect after any close the app did not ask for, with backoff 1, 2, 4, 8, then 10 seconds.
  9. Send values as the JSON types above, and operator objects as given; a literal value that looks like an operator goes in { "$set": v } (values) or { "$eq": v } (expect).

Should

  1. Offer helpers for the operators (inc, dec, min, max, append, remove), transact, and a read-modify-write helper that writes with expect on the values it read and retries 409s that carry no column, columns, check or constraint (with a random wait up to 200 ms x 2n).
  2. Report a connection status: connecting, open, reconnecting, resyncing, closed.
  3. Wait Retry-After seconds after a 429 before trying again.
  4. Store the hosted sign-in session (the pair) in the platform's secure storage and restore it while the refresh token is valid.
  5. Pass the conformance suite.

The conformance suite

conformance/ in the repository holds a fixed project definition (project.json: tables whose rules cover anyone, authenticated, roles, owner, nobody, a unique column and a check), more than 50 cases as JSON, and a runner that replays them against any server through any SDK:

bash
bun conformance/run.ts --base http://localhost:4600 --admin-key "$DABADATA_ADMIN_KEY" --adapter all

The runner creates a throwaway project with the server's admin API, mints tokens for the personas (alice is an admin, bob an editor, carol a viewer, plus anonymous and the secret key), empties the tables before every case, runs it, prints a report and deletes the project. Each case is a list of steps: who acts (as), a raw HTTP call or an SDK op (insert, update, delete, query, transact, subscribe, unsubscribe, refresh, setToken), and what must come back: status, a subset of the body (bodyMatch) and the exact live messages each subscription receives (liveMessages).

An SDK joins by implementing the adapter interface in conformance/lib/types.ts: open a session per case, run an op as a persona and answer { status, body } in the canonical shape, and hand over the live messages a subscription received. adapters/raw.ts is the protocol written out by hand in about 200 lines, a good template for a port; adapters/js.ts drives the JavaScript client. Both pass every case.

dabadata documentation, written against API contract v1.