Skip to content
AbloAblo Docs
Esc
navigateopen⌘Jpreview
On this page

Schema Contract

One schema becomes typed clients, agent writes, interface reads, and the hosted push.

Ablo’s schema is the integration contract. Define it once, pass it to Ablo(...), and every actor gets the same typed model surface:

defineSchema(...) -> ablo.<model>.create/get/update/claim(...)

That one object drives:

  • typed model clients in trusted server runtimes,
  • React selectors through useAblo((ablo) => ablo.<model>.local.get(id)),
  • agent and background-worker writes,
  • Data Source request/response shape when your database stays canonical,
  • hosted schema push, migration planning, and schema-version gating.

Minimal shape

import Ablo from '@abloatai/ablo';
import { defineSchema, model, z } from '@abloatai/ablo/schema';

export const schema = defineSchema({
  weatherReports: model({
    location: z.string(),
    status: z.enum(['pending', 'ready']),
    forecast: z.string().optional(),
  }),
});

export const ablo = Ablo({
  schema,
  apiKey: process.env.ABLO_API_KEY,
});

await ablo.ready();

const report = await ablo.weatherReports.create({
  data: {
    location: 'Stockholm',
    status: 'pending',
  },
});

The model key (weatherReports) becomes the client namespace (ablo.weatherReports). The Zod fields become the create/update/read type contract. You should not create a parallel string-keyed write path for the same data.

Reserved fields

The SDK provides these on every row automatically — do not declare them in your model(...) fields:

  • id
  • createdAt
  • updatedAt
  • organizationId
  • createdBy

Declare only your own fields; the reserved ones are still present on the row and readable, you just don’t author them.

Reads and writes

Use async reads when the row may not be local:

const report = await ablo.weatherReports.get({ id: reportId });
const ready = await ablo.weatherReports.list({ where: { status: 'ready' } });

Use synchronous local reads in render after data has synced:

const report = ablo.weatherReports.local.get(reportId);
const pending = ablo.weatherReports.local.list({ where: { status: 'pending' } });

Use model writes for every actor:

await ablo.weatherReports.update({ id: reportId, data: { status: 'ready' } });

Coordination

Agents and background jobs often read, call a tool or model, then write later. Wrap that slow span in claim:

const handle = await ablo.weatherReports.claim({ id: reportId });
const forecast = await getForecast(handle.data.location);
await ablo.weatherReports.update({ id: handle.data.id, data: { status: 'ready', forecast } });
await handle.release();

If another writer already holds the row, claim waits, re-reads, and hands you the fresh row. Reads stay open; only acting on the row serializes.

Storage boundary

Every schema model is backed by your own database, and you write to it through ablo.<model>. There are three start states, all covered in Connect Your Database (the single source of truth): a development branch with no database yet (apiKey only — Ablo keeps that branch’s rows in its own log), npx ablo connect (a scoped writer role plus logical replication, so Ablo writes your rows and confirms them over the WAL), or a signed Data Source endpoint when your database can’t grant replication.

Your database connects out of band, so the client holds only ABLO_API_KEY — never a connection string. Browser code goes through <AbloProvider> or a scoped session route, never a raw API key.

Rules of thumb

  • Start with fields and relations before load/index tuning.
  • Import one schema into app code, server actions, agents, and Data Source routes.
  • Keep direct database writes out of the coordinated path unless they are reported back through Data Source events.
  • Use claim for slow read -> think -> write spans.
  • Use readAt + onStale: 'reject' when a write must fail if the row changed after it was read.

For the shortest runnable path, start with Quickstart. For a production app, continue with Integration Guide.

Was this page helpful?