ObjectStackObjectStack
Custom Front-end

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-objectstack
import { 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.url with GET, through the host's authenticated fetch (SchemaRendererProvider's apiFetch; the console passes its signed-in fetch), and selects path from the answer — dotted keys, a numeric segment indexing a list. Omit path to bind the whole answer.
  • The selected value is data in the node's ${…} holes and in its visibleWhen, 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 path that 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 a method other than GET is 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.

On this page