Client Behavior
Guarded writes, claim behavior, and which errors are safe to retry.
When several writers touch the same data at once — an agent worker, a Server Action, a person in the browser — the SDK protects explicit read dependencies and claims records across slow work. This page describes those guarantees and which errors are safe to retry.
Claims don’t lock. If another writer holds the row, claim waits for them, re-reads the fresh row, then hands it to you — so two writers serialize instead of clobbering.
Constructor
import Ablo from '@abloatai/ablo';
import { defineSchema, model, z } from '@abloatai/ablo/schema';
const schema = defineSchema({
weatherReports: model({
location: z.string(),
status: z.enum(['pending', 'ready']),
}),
});
const ablo = Ablo({
schema,
apiKey: process.env.ABLO_API_KEY,
});
The package-root export is the headless coordination client for agents, workers, route handlers, and other server operations. Trusted API-key clients use HTTP. Scoped session clients use one reconnecting WebSocket for commits and live coordination; point reads and administration remain HTTP. See Transports for the lifecycle and Options for the constructor. A human-facing local graph is added through the React client.
Your database connects out of band — through logical replication (npx ablo connect), or the signed Data Source endpoint as the
fallback for databases that can’t grant replication — so the client holds only
apiKey, never a connection string. See
Connect Your Database for the full setup.
Model Methods
Each schema model becomes a typed model:
await ablo.ready();
const report = await ablo.weatherReports.read({ id: 'report_stockholm' });
const local = ablo.weatherReports.local.get('report_stockholm');
await ablo.weatherReports.create({ data: { location: 'Stockholm', status: 'pending' } });
await ablo.weatherReports.update({ id: 'report_stockholm', data: { status: 'ready' } });
await ablo.weatherReports.delete({ id: 'report_stockholm' });
On the reactive client, each model write changes local state optimistically
before the call returns. Its promise always waits for authoritative
confirmation, so await update(...) is the confirmation barrier.
Call get/list to observe, or read when a later mutation depends on the row.
After that, local.get/local.list/local.count read the already-synced data instantly with
no await, and stay reactive in render. Use the async pair to load, the sync trio
to read.
local.list accepts the same practical read options the React selector path uses:
where, filter, orderBy, limit, offset, and state. The state
lifecycle filter defaults to 'live'; pass 'archived' or 'all' when you
intentionally want non-live rows.
Browser read freshness and persistence
These modes apply after await ablo.ready() on the reactive browser client.
Reads before initialization provide no persistence guarantee. A headless HTTP client has no
local graph or IndexedDB replica; its reads go to the server.
| Read | Can return cached data? | Network | Reactive graph | Offline / missing | Persistence when resolved |
|---|---|---|---|---|---|
local.get, local.list, local.count |
Yes; memory only | None | Read only | Available offline; undefined, [], or 0 means absent locally |
No write or durability barrier |
list with omitted type or type: 'unknown' |
Yes; memory, then IndexedDB | Cold queries block; warm queries confirm in the background once per connection | Hydrates accepted rows | Warm data can be stale; cold network failure rejects; a successful empty network answer returns [] |
A local hit does not wait for background writes; a network result waits for its accepted rows’ storage transactions |
list({ type: 'complete' }) |
Never substitutes cached rows for an empty server answer; newer resident versions can supersede returned snapshots | Always awaits a query, including when local data exists | Hydrates accepted rows | Network failure rejects without stale fallback; missing matches return [] |
Waits for accepted primary and expanded rows’ storage transactions; storage failure rejects |
get({ id, type? }), read({ id, type? }) |
The query hydration stage follows the list policy |
Also performs an authoritative point read after hydration | The query stage updates the graph; the additional point response does not | Point-read failure rejects even with warm data; missing row returns undefined |
Query-stage writes finish first; the separate point response is not itself persisted |
An unhydrated query with expand waits for the network even when its parent is
cached: a parent alone cannot establish that its children are loaded. Omitted
type is local-first for every model load strategy. Reconnecting clears the
query hydration ledger. complete describes freshness, not an unlimited result
set; keep queries bounded and inspect the collection’s hasMore.
Query responses meet resident rows by server log position, not updatedAt. A
snapshot known to precede an accepted subscription version cannot replace that
resident row or overwrite its persisted data. Pending local edits remain visible.
An empty query result does not delete cached rows: filtered or limited query
absence is not a deletion event. Synchronized deletes remove rows from the graph;
a local selector then observes their absence.
For a reload-sensitive external publication, explicitly hydrate the desired rows:
await ablo.ready();
// The application server has already confirmed its publication.
await ablo.sourceSnapshots.list({ where: { id: snapshotId }, type: 'complete' });
await ablo.sourceHeads.list({ where: { id: headId }, type: 'complete' });
// Accepted query rows have reached the configured local storage.
Configure persistence: 'indexeddb' on the browser client for reload persistence.
Memory persistence cannot survive a reload. IndexedDB completion here means a
completed browser transaction, using relaxed durability; it is not a guarantee
against power loss, browser eviction, or a later authorized update or deletion.
The barrier covers these queries’ accepted writes, not every pending subscription
or mutation. waitForFlush() waits for server mutation confirmation and is not a
local persistence barrier. If a storage write fails, the graph may already show
the result; retry the complete read before relying on reload persistence.
Use read when a later write needs exact captured read evidence. Use a complete
list query when the purpose is to populate and persist the reactive working
set. A get/read result and the local graph are distinct snapshots and can differ
if the row changes between the query and point requests.
Account changes require disposing the old client and constructing a new scoped client, as shown in React. Do not reuse an old request’s result as initial data for the new account. The same user, project and branch do not make two accounts the same persistence authority.
Multiplayer Behavior
Two writers both try to mark report_stockholm ready at the same time. To stop
the second write from silently overwriting the first, every participant goes
through the same model client path. A human Server Action, a browser view, and an
agent worker can all use ablo.weatherReports:
const report = await ablo.weatherReports.read({ id });
if (!report) throw new Error('Row not found');
await ablo.weatherReports.update({
id,
data: patch,
reads: [report],
});
Once the server accepts the write, every other connected client gets the new row
automatically — no polling or manual refresh on your side. React clients that use
useAblo((ablo) => ablo.weatherReports.local.get(id)) receive the new row, and selectors
such as useAblo((ablo) => ablo.weatherReports.claim.state({ id }))
receive active claim state. There is
no extra multiplayer setup beyond routing shared state through Ablo.
Writes flow through Ablo’s commit chokepoint and land in your database, so every actor routing through Ablo is coordinated. The one write it can’t coordinate is one made directly against your database, around Ablo — the WAL echo still catches it for reads, but it bypasses claims and ordering.
Guarded Writes
const report = await ablo.weatherReports.read({ id: 'report_stockholm' });
if (!report) throw new Error('report not found');
await ablo.weatherReports.update({
id: report.id,
data: { status: 'ready' },
reads: [report],
idempotencyKey: 'report_stockholm:mark-ready:v1',
});
| Option | Purpose |
|---|---|
reads |
Exact rows returned by read that this mutation depends on. |
idempotencyKey |
Stable key for retry-safe writes. The SDK generates one when omitted. |
A stale premise always rejects with AbloStaleContextError. Omit reads only
when the assignment is intentionally unconditional.
Claimed Behavior
If your update involves a slow step — an API call, an LLM round-trip — and someone
else might write the same record meanwhile, claiming the record stops you from
overwriting their change. Check who holds the record with claim.state({ id }), then
take it with claim({ id }):
const active = ablo.weatherReports.claim.state({ id: 'report_stockholm' });
if (active) {
return { status: 'claimed', active };
}
const handle = await ablo.weatherReports.claim({ id: 'report_stockholm' });
await ablo.weatherReports.update({
id: handle.data.id,
data: { status: 'ready' },
claim: handle,
});
await handle.release();
claim.state({ id }) returns the current holder (or nothing) without ever blocking.
When you call claim({ id }), the SDK queues other claimers behind you, re-reads
the latest row, then hands you the fresh row — so you can’t overwrite a change you didn’t
see. Options on the claim:
- default
claimwaits in the fair queue and re-reads before handing you the row; { queue: false }resolvesnullwhen another participant already holds the target; two clients with the same participant identity are re-entrant, not contenders;{ maxQueueDepth }rejects if the wait line is already too deep.
While waiting, schema clients learn when the claim clears from the live claim stream, so they never poll. Headless HTTP clients poll the same durable queue; the model client keeps the row target on each heartbeat, so a holder releasing cannot make the queued ticket unresolvable by id. During fence minting, it also keeps polling through a visibility miss only while the server’s last enqueue or heartbeat acknowledgement still guarantees that ticket is live.
Errors
All SDK errors extend AbloError. type is the class-name discriminator, such
as AbloStaleContextError; code is the wire condition, such as
stale_context. Use instanceof in-process and type after serialization.
Every error also exposes a typed recovery classification and retryable
boolean. When the server requests a minimum delay, retryAfterSeconds is
present on the same error for both 429 and 503 responses.
| Error | Typical cause |
|---|---|
AbloAuthenticationError |
Missing, invalid, or expired credential. |
AbloPermissionError |
Valid credential, denied operation or scope. |
AbloRateLimitError |
Rate limit or quota exceeded. Check retryAfterSeconds. |
AbloIdempotencyError |
Same idempotency key reused with a different request. |
AbloConnectionError |
Network, timeout, abort, or transport failure. |
AbloValidationError |
Invalid input or unsupported request shape. |
AbloServerError |
Server-side 5xx. Retry with backoff if the operation is idempotent. |
AbloStaleContextError |
Write was based on stale readAt state. Re-read and retry. |
AbloClaimedError |
A write conflicted with another participant’s active claim, the queue was too deep, or a claim wait timed out. |
import { AbloClaimedError } from '@abloatai/ablo';
try {
await ablo.weatherReports.update({ id: 'report_stockholm', data: { status: 'ready' } });
} catch (error) {
if (error instanceof AbloClaimedError) {
return { status: 'claimed' };
}
throw error;
}
Retries and Idempotency
Model writes are retry-safe by default because the SDK attaches an idempotency key. If you provide your own key, keep it stable for retries of the same logical operation and never reuse it for a different payload.
Retry transport failures and 5xx with backoff. For example, an
instance_at_capacity error has recovery === 'transient'; wait at least
retryAfterSeconds before replaying the unchanged request. The headless HTTP
client performs that exact replay within timeoutMs; importantly, it does not
restart a larger claim/read/write workflow around the rejected request. If the
deadline is exhausted, the same actionable error reaches the caller. Do not
blindly retry validation, permission, idempotency, or stale-context errors
without changing the request.
Logging
Pass a logger when you need SDK logs in your own observability pipeline:
const ablo = Ablo({
schema,
apiKey: process.env.ABLO_API_KEY,
logger,
});
The logger receives lifecycle, sync, retry, and rollback events. Avoid logging request bodies that may contain customer data.
Public Imports
Only these imports are public SemVer surface:
@abloatai/ablo@abloatai/ablo/schema@abloatai/ablo/react
dataSource(...) is exported from the root package for customer-owned storage
adapters. Everything outside the three import paths is internal to Ablo-owned
apps and infrastructure. For adapter authors, @abloatai/ablo/source/conformance
is the suite that proves a storage adapter behaves correctly.