Read, write, and subscribe
Import createClient, the schema/query builders, and isoTx from r2db. A client holds an account API token and addresses one database. Use it in trusted Worker or service code.
import { createClient, newQueryBuilder, isoTx } from 'r2db';
import { schema } from './schema';
const db = createClient({ url, token, databaseId, schema });
const q = newQueryBuilder(schema);
Reads
const rows = await db.exec(q.task.where.done(false).limit(50));
const { rows: observed, version } = await db.execAt(q.task.limit(50));
const { results } = await db.execMany([q.task.limit(5), q.task.where.done(true).limit(5)]);
Queries accept builders or JSON ASTs. exec returns the assembled result; a builder ending in one() returns a row or null. HTTP POST /v1/databases/ID/query accepts {query: AST} or {queries: AST[]} and returns {rows, version} or {results, version}. A batch contains at most 16 queries.
Atomic writes and uncertain responses
const result = await db.write([
isoTx.insert('task', { id: 'a', title: 'Ship the app', done: false }),
isoTx.update('task', { id: 'b', done: true }),
], { idempotencyKey: 'operation-123' });
All operations commit together. Supported verbs are insert, upsert, insertIgnore, update, and delete. update and delete do nothing when a key is absent; upsert may create it. HTTP POST /v1/databases/ID/write accepts {ops, expectVersion?}, up to 100 operations. The idempotency key travels in the Idempotency-Key header.
The SDK generates a key for each write call and retries transient transport failures using that same key. To recover across application requests or process restarts, supply your own stable key. A receipt and the database pages commit in the same durable storage transaction. Repeating the same key and payload returns the original version; changing its payload is refused. Keys are retained for 24 hours, after which the service may treat the key as new.
A ConflictError means the conditional write was refused. Validation errors are also definite refusals. WriteOutcomeUnknown means the response could not establish whether the write committed; it carries idempotencyKey. Retry the same operations, expected version, and key within the retention window. Do not turn an uncertain outcome into a fresh write. Every admitted HTTP attempt remains subject to request billing.
Read-modify-write
await db.readModifyWrite(async tx => {
const tasks = await tx.exec(q.task.where.id('a').limit(1));
if (!tasks.length) return [];
return [isoTx.update('task', { id: 'a', done: true })];
});
This callback runs in your Worker and may run several times. It reads a consistent store version, returns operations, and commits only if that version still matches. Keep side effects after the call resolves. tx.now stays stable across attempts; tx.id() generates a sequence of distinct IDs that repeats across attempts. An empty operation array writes nothing. Retry count is bounded (five attempts by default).
HTTP writes can alternatively pass expectVersion from execAt. An unrelated commit can invalidate that whole-store version, so contention may cause conservative retries.
Interactive transactions
const { value, version, rounds } = await db.transact(tx => {
const task = tx.row('task', { id: 'a' });
if (!task) return { found: false };
tx.update('task', { id: 'a', n: task.n + 1 });
return { found: true, was: task.n };
});
The same callback the facet API below takes, driven from your own Worker over HTTP. It is synchronous, runs in your process, and may run several times: statements it cannot answer yet return a placeholder, the callback continues past them, and the whole batch is fetched in one request — so the number of requests is the callback's read-dependency depth plus one, not its statement count. Independent reads cost one request between them.
Validation is per key. The commit refuses only when something the callback actually read has changed; a concurrent write to a row it never read is not a conflict. A refusal is handled by the loop, which re-runs the callback over the current rows and commits that decision, so your code sees a committed transaction and a conflicts count rather than an error. conflicts is 0 when nothing contended it.
Each round is one billed request. readModifyWrite above is two requests and is the cheaper shape when one read and one conditional write is all the work needs; transact is the shape for a callback whose later reads depend on its earlier ones. Pass onCrossing to count the round trips a callback cost, and read gateWaits beside conflicts to tell a round that waited for another writer from one that was contended by a commit.
Rounds and commits that run slowly or are refused appear in the slow query log, as their own kinds. Each round runs under the service's per-round ceilings; pass limits to tighten them for a callback that would rather fail fast. A value above a ceiling is refused with a message naming it rather than silently applied.
The replay rules are the same as readModifyWrite's and matter more here, because the callback runs once per round rather than once per attempt: take the clock from tx.now and IDs from tx.id(), keep external effects in tx.after (which runs after the commit lands) or after the call resolves, and pass development: true to createClient to have your own assertions see the extra run. A validation set too large for the wire is dropped, and the commit then falls back to the whole-store version comparison — a conservative direction: more retries under contention, never a wrong commit. Too large means either bound: the serialized size, or the number of entries, which a callback reaches by reading many rows across several rounds rather than by reading large ones.
Nothing is held between rounds. An open transaction takes no lock, keeps no open store transaction and pins no version — the version it validates against travels on each request — so other transactions and the background base publication run throughout, and a callback that abandons its transaction (or a client that disappears) costs nothing but the rounds it already paid for.
Live results
const live = await db.subscribe(q.task.where.done(false).limit(50), { signal });
console.log(live.data);
const detach = live.subscribe(rows => render(rows));
live.onError(error => report(error));
// Later:
detach();
live.close();
subscribe resolves after the authoritative initial snapshot, including an empty one. Listener registration immediately receives the current result. Later committed deltas update it. The handle owns its socket, leases, reconnects, and cleanup. live.status reports connecting, live, reconnecting, error, or closed; live.error describes a reported failure.
After a disconnect or protocol gap, the SDK obtains current authorization and replaces the old result with a fresh snapshot. Existing rows remain visible during recovery. This is live query state, not a durable event log: do not treat each callback as an exactly-once business event. Initial loading has a 30-second timeout; timeoutMs and signal are optional subscribe options.
The lower-level wire remains available: POST /v1/databases/ID/materialize mints a query-scoped 60-second lease, and /v1/databases/ID/socket accepts its WebSocket upgrade. The initial hello and snapshot precede committed deltas. The public SDK manages these steps.
Live results in a browser
A page cannot hold your account token, so it watches a query with a lease your server mints for it. The server decides which query a page may watch; the page opens r2db's socket itself.
// Your Worker: the page names a query, you build it and mint a lease on exactly it.
const lease = await db.lease(q.task.where.done(false).limit(50));
return Response.json(lease); // { leaseToken, expiresInMs, socketUrl }
// The page.
import { subscribe } from 'r2db/browser';
const live = await subscribe(q.task.where.done(false).limit(50), {
schema,
lease: signal => fetch('/api/lease', { method: 'POST', body, signal }).then(r => r.json()),
});
live.subscribe(rows => render(rows));
The result behaves exactly as subscribe's does above. A lease names one query and lasts a minute, so the socket can carry that result and nothing else, and lease is called again whenever the socket reconnects: have it ask your server each time rather than cache. Never pass the page's own description of a query to db.lease — build the query on the server from a name and checked arguments, the way you would choose which SQL a request may run. Minting is one billed request; the socket's admission is billed when it opens. The public demo at https://demo.r2db.dev is built this way.
Development guardrails
const db = createClient({ url, token, databaseId, schema, development: true });
console.log(await db.explain(q.task.where.done(false).limit(50)));
explain returns schema/index advice and possible scan warnings; it is not a cost-based execution plan. A joined subquery is advised by its correlation: a join on a declared index reads one parent's rows and is not a warning, and a join whose correlation column no index covers is one. Development mode emits each warning once and probes readModifyWrite callbacks with the same recorded reads before writing. A changed read sequence or operation batch raises ReplayUnsafe. This catches some replay mistakes, not every external side effect; callbacks must still be safe to rerun.
GET /v1/databases/ID/schema reads the live schema. Database creation accepts {name, schema: TableImage[]} at POST /api/databases; createDatabase({url, token, name, schema}) and r2db create derive that image from your TypeScript declaration. Public schema evolution is not exposed in this preview.
Optional facet API
Deploy a Workers-compatible fetch handler or DurableObject class with r2db deploy --database ID --entry FILE. The platform supplies AppEnv.db; the facet owns its routes and receives neither the engine nor R2 credentials. Outbound networking is disabled.
const conn = env.db.newConnection();
try {
const result = await conn.transact(tx => {
const task = tx.row('task', { id });
if (!task) return { found: false };
tx.update('task', { id, done: true });
return { found: true };
}, { replayCheck: true }); // Development aid; leave off in production.
return Response.json(result.value);
} finally { conn.close(); }
Facet callbacks are synchronous and may replay while reads resolve or after conflicts. Use tx.now; tx.id() is the stable transaction ID (use a suffix or precomputed IDs for multiple inserted rows). Keep external effects out of the body. tx.after runs after commit; an effect failure does not undo the committed write. replayCheck probes a completed callback without committing its probe writes or after effects.
Facet transactions run concurrently and validate at commit; commits serialize. The HTTP transact above is the same mechanism with requests in place of in-object calls, so a callback written for one runs on the other unchanged. Maintained subscriptions and migrations through the facet are not exposed. Preview /apps/ID routes require the owner's token or session.
Errors and limits
HTTP 400 rejects malformed input; 401/403 requires valid authorization; 402 requires credits; 404 hides another account's resources; 409 reports a conflict, incomplete creation, or incompatible idempotency reuse; 422 reports database/application failures. Reads and writes have bounded work budgets. A budget failure needs a smaller query or a covering index, not an unlimited retry loop.
Health
Your database runs as one Durable Object. Cloudflare may run several objects of the same class in one isolate, sharing its 128 MB of memory, and when that isolate runs out the runtime finishes the requests already in flight and moves subsequent ones to a fresh isolate. Your database is restarted when that happens — including when the memory was another database's.
GET /v1/databases/ID/probe?samples=N times N calls from the Worker to your database's object and reports the distribution. It measures the platform call and nothing inside the object — no store is opened and no counter moves — so it answers "how far away is my database from the Worker that serves it", not "how fast is my query". One probe is one billed request however many samples it takes; N is capped at 64. Do not poll it: like health, any request resets the hibernation window.
A restart does not lose data. Committed writes are already in R2 or in the object's own storage, and base publication is fenced by a conditional write, so an interrupted publish is rejected rather than half-applied. What a restart does cost is warm state: maintained queries re-hydrate, the page cache refills from R2, and the first read afterwards is slower.
GET /v1/databases/YOUR_DATABASE_ID/health
Authorization: Bearer YOUR_TOKEN
The response reports:
restarts.incarnations— how many times the database has started up and served work.restarts.abnormal— how many of those followed a restart that happened while the database was busy. Routine restarts are expected and are not counted here: an idle database is put to sleep after about ten seconds and woken by the next request. A risingabnormalcount is the signal worth acting on.restarts.lastAbnormalAtandrestarts.lastGapMs— when the last one happened, and how long the database had been quiet before it.resident— whether the store is currently open in memory. A sleeping database answersfalseand reports no page figures. Reading health never opens the store, so it costs no R2 reads and does not warm anything.pages— how much memory the open store is holding, and how large the base and overlay have grown.
This endpoint is free and is not metered, so it keeps working when an account is out of credit. Do not poll it on a timer: any request keeps the database awake, and duration is billed while it is, so a scraper would hold every database resident. Read it when you want to know, not continuously.
pages.pageRamBudgetBytes is the budget for the whole isolate, which co-located databases share. A single database approaching it is not by itself the problem; the problem is the total across whichever databases happen to share the isolate, and that total is not visible from inside one of them. The abnormal count is the usable signal.