Data Connectivity
ObjectUI follows the Universal Adapter Pattern. UI components do not hardcode transport details. They receive a DataSource implementation from SchemaRendererProvider and call a stable CRUD/query contract.
This keeps the renderer backend-agnostic: ObjectStack, REST, GraphQL, and proprietary backends can all be adapted behind the same interface.
The Interface
The canonical interface lives in @object-ui/types:
import type { BatchTransactionOperation, QueryParams, QueryResult } from '@object-ui/types';
export interface DataSource<T = unknown> {
find(resource: string, params?: QueryParams): Promise<QueryResult<T>>;
findOne(resource: string, id: string, params?: QueryParams): Promise<T | null>;
create(resource: string, data: Partial<T>): Promise<T>;
update(
resource: string,
id: string,
data: Partial<T>,
opts?: { ifMatch?: string },
): Promise<T>;
delete(
resource: string,
id: string,
opts?: { ifMatch?: string },
): Promise<boolean>;
// Optional: atomically persist an ordered set of cross-object operations
// (master-detail save). `{ $ref: <op index> }` links a child to a parent
// created earlier in the same batch. Adapters without server-side atomicity
// may emulate it — see below.
batchTransaction?(
operations: BatchTransactionOperation[],
): Promise<{ results: any[] }>;
getObjectSchema(objectName: string): Promise<unknown>;
}find() returns a QueryResult<T> so components can receive both rows and pagination metadata:
interface QueryResult<T = unknown> {
data: T[];
total?: number;
page?: number;
pageSize?: number;
hasMore?: boolean;
cursor?: string;
metadata?: Record<string, unknown>;
}Available Adapters
ObjectStack Adapter (Official)
Use @object-ui/data-objectstack for ObjectStack-compatible backends.
pnpm add @object-ui/data-objectstackimport { createObjectStackAdapter } from '@object-ui/data-objectstack';
const dataSource = createObjectStackAdapter({
baseUrl: 'https://api.your-instance.com'
});Usage
Inject the data source at the renderer boundary:
import '@object-ui/components';
import '@object-ui/fields';
import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
import { createObjectStackAdapter } from '@object-ui/data-objectstack';
import type { DeclaredNode } from '@object-ui/types';
const dataSource = createObjectStackAdapter({
baseUrl: 'https://api.example.com'
});
// `object-grid` is a data-bound node: it reads `objectName` from its
// `properties` bag and queries the injected data source for the rows.
const mySchema: DeclaredNode = { type: 'object-grid', properties: { objectName: 'users' } };
function App() {
return (
<SchemaRendererProvider dataSource={dataSource}>
<SchemaRenderer schema={mySchema} />
</SchemaRendererProvider>
);
}Creating a Custom Adapter
If you have a proprietary backend, wrap its SDK or client in a DataSource implementation. Keep transport details in the adapter, not in renderers.
import type { DataSource, QueryParams, QueryResult } from '@object-ui/types';
type User = {
id: string;
name: string;
email: string;
};
type BackendClient = {
listUsers(params?: QueryParams): Promise<{ rows: User[]; total?: number }>;
getUser(id: string | number): Promise<User | null>;
createUser(data: Partial<User>): Promise<User>;
updateUser(id: string | number, data: Partial<User>): Promise<User>;
deleteUser(id: string | number): Promise<boolean>;
describeObject(name: string): Promise<unknown>;
};
class UserDataSource implements DataSource<User> {
constructor(private readonly client: BackendClient) {}
async find(resource: string, params?: QueryParams): Promise<QueryResult<User>> {
if (resource !== 'users') {
return { data: [], total: 0 };
}
const result = await this.client.listUsers(params);
return {
data: result.rows,
total: result.total,
};
}
findOne(_resource: string, id: string | number): Promise<User | null> {
return this.client.getUser(id);
}
create(_resource: string, data: Partial<User>): Promise<User> {
return this.client.createUser(data);
}
update(_resource: string, id: string | number, data: Partial<User>): Promise<User> {
return this.client.updateUser(id, data);
}
delete(_resource: string, id: string | number): Promise<boolean> {
return this.client.deleteUser(id);
}
getObjectSchema(objectName: string): Promise<unknown> {
return this.client.describeObject(objectName);
}
}Query Parameters
ObjectUI uses OData-style query keys for broad compatibility:
await dataSource.find('users', {
$select: ['id', 'name', 'email'],
$filter: { status: 'active' },
$orderby: { name: 'asc' },
$skip: 0,
$top: 25,
$count: true,
});Data-aware plugins may also use optional methods such as batchTransaction, bulkUpdate, bulkDelete, getView, or listViewOverrides when an adapter supports them. Keep the required CRUD methods implemented first, then add optional capabilities as your UI needs them.
Bulk writes: per-record results and one refresh per run
bulkUpdate and bulkDelete resolve to either a plain count or a BulkWriteResult: the count plus one { id, error } entry per record the server refused, with the server's reason. An adapter whose server reports per-row outcomes should return the BulkWriteResult (the @object-ui/data-objectstack adapter does, from the results[].errors[] of updateMany / deleteMany). An adapter that has only a count keeps returning the number. A caller handles both:
import type { DataSource } from '@object-ui/types';
declare const dataSource: DataSource;
const res = await dataSource.bulkUpdate?.('order', ['o1', 'o2', 'o3'], { status: 'closed' });
if (typeof res === 'number') {
console.log(`${res} updated`);
} else if (res) {
console.log(`${res.succeeded} updated`);
for (const { id, error } of res.errors) console.warn(`${id}: ${error}`);
}The grid's bulk actions read both shapes. A refused record shows up in the result dialog and the error CSV under its own id and reason, and Retry works on it, whatever the action's batchSize.
Every create / update / delete normally fires one onMutation event, and every bound view refetches once per event. A loop that writes many rows (a bulk action on its per-row path, an undo) would refetch the list once per row. Wrap such a loop in the optional batchMutationEvents(work): the events its writes fire are held and delivered when work settles, one per touched resource and mutation type, so bound views refresh once. The grid's bulk executor does this when the adapter has the method.
import type { DataSource } from '@object-ui/types';
declare const dataSource: DataSource;
const ids = ['o1', 'o2', 'o3'];
const write = async () => {
for (const id of ids) await dataSource.update('order', id, { status: 'closed' });
};
await (dataSource.batchMutationEvents ? dataSource.batchMutationEvents(write) : write());listMetadataItems(type) lists one metadata registry type as { name, label } headers, for the position-ref / permission-ref field widgets (see the Fields guide). An empty array means the registry has no item of that type, so a failed load must reject, never resolve [].
Cross-object atomic writes (batchTransaction)
Master-detail saves (a parent record plus its child line items) go through
dataSource.batchTransaction(operations) — one ordered list of cross-object
create/update/delete operations, where a child's foreign key can be
{ $ref: <parent op index> } to point at a parent created in the same batch.
The @object-ui/data-objectstack adapter maps this to the published
@objectstack/client data.batchTransaction SDK method, which drives the
server's atomic POST /api/v1/batch endpoint (commit-all-or-roll-back-all).
Adapters without a
transactional endpoint don't need to hand-write orchestration: call
emulateBatchTransaction(dataSource, operations) from @object-ui/core, which
executes the operations sequentially (resolving $refs) with best-effort
compensation on failure. UI components never branch on atomicity — they call
runBatchTransaction(dataSource, operations) (also from @object-ui/core),
which uses the adapter's method when present and emulates otherwise.
The @object-ui/data-objectstack adapter decides whether it can trust server
atomicity declaratively, at connect time: it reads the
capabilities.transactionalBatch flag from GET /api/v1/discovery
(framework #3298). When the backend advertises true, the adapter treats any
/batch failure as a real error — no non-atomic client-side compensation. When
the flag is false or absent (a backend predating #3298), it keeps the legacy
behaviour: probe /batch and fall back to the non-atomic emulation on
404/405/501. Atomic cross-object saves are therefore guaranteed only
against backends that advertise the capability; older ones still save, but
best-effort. See the
adapter README
for the full capability table and minimum-backend note.
Per-element data binding on a page (dataSource)
A metadata page component carries its own data binding —
PageComponentSchema.dataSource, the spec's ElementDataSourceSchema — so one
page can show several objects without a page-level object context:
{
"type": "list-view",
"dataSource": { "object": "account", "view": "hot", "limit": 10 }
}This is metadata, not the data-source adapter. The two share a name and are
different things: the adapter is injected by the host (SchemaRendererProvider),
while dataSource on a schema node is JSON describing what to query. A
renderer therefore reads the binding off schema.dataSource and gets its adapter
from context — never from a prop the schema could occupy. SchemaRenderer strips
the binding from the props it spreads for exactly this reason.
@object-ui/react exposes useElementDataSource(schema, dataSource?) for
renderers that need the same resolution, and @object-ui/core exposes the pure
parts (isElementDataSourceConfig, resolveSavedView,
composeElementDataSource).
Which keys each page block honours, and how a saved view combines with the binding's own
filter, sort and limit, is page metadata, documented under
Element data sources.
Binding a page element to an API endpoint (provider: 'api')
PageComponentSchema.dataSource has a second shape beside the object binding:
the spec's ElementApiDataSourceSchema. It binds one same-origin endpoint
instead of the rows of an object. Its authoring rules are under
Element data sources; this section is what an SDK host
provides for it:
{
"type": "stat",
"dataSource": { "provider": "api", "read": { "url": "/api/v1/cloud/home" }, "path": "data" },
"properties": { "label": "Members", "value": "${data.members.count}" }
}SchemaRenderer honours it on every node:
- It reads
read.urlwithGET, through the host's authenticated fetch (SchemaRendererProvider'sapiFetch; the console passes its signed-in fetch), and selectspathfrom the answer — dotted keys, a numeric segment indexing a list. Omitpathto bind the whole answer. - The selected value is
datain the node's${…}holes and in itsvisibleWhen, and in the holes of everything the node renders. A hole that is the whole string ("${data.members.count}") evaluates to the value itself — a number, a boolean, a list — rather than to text. - While the read is in flight the node draws a loading line; a failed read, or
a
paththat selects nothing, draws an error panel naming why. The node is never drawn without its data. - The binding is judged by the spec's own schema before anything is read. An
absolute or protocol-relative
url, a credential key, or amethodother thanGETis refused with the spec's message, and no request is sent.
Elements that bind the same URL at the same time share one request. A host can
also keep one open's answers: inside ElementApiOpenScope (from
@object-ui/react), a read that has already answered in the same open is
answered again with no request. The console mounts it and counts one open per
visit of a history entry, so a navigation entry's visible and a page element bound to
the same URL make one request, even when the entry's read settled first:
- following a link, a navigation entry, or Back and Forward starts a new open, and the page reads again, even on an entry visited before;
- a redirect that replaces the address (the app root's landing on its first entry) stays in the open;
- a failed read is not kept, and any declared data change (
notifyDataChanged, which every save and action reports) drops what the open kept, so a read after a write asks the server.
Outside such a scope nothing is kept beyond the request in flight: a page that mounts again reads again.
The console's product-page blocks — hero, stat, usage-bar, checklist and
tile-grid — accept this binding in their arms; see the @object-ui/app-shell
README.