| title | ObjectStack Data Adapter |
|---|---|
| description | Data adapter for connecting ObjectUI to ObjectStack backends |
The @object-ui/data-objectstack package provides a data adapter that connects ObjectUI to ObjectStack backends. It enables seamless integration with ObjectStack's data layer, supporting CRUD operations, queries, and real-time updates.
npm install @object-ui/data-objectstackNote: @objectstack/client is a regular dependency of this package — it is installed and resolved along with it, so there is nothing to install separately.
The ObjectStack adapter bridges ObjectUI's schema-driven UI with ObjectStack's data backend, providing:
- 🔌 Automatic Integration - Connect to ObjectStack APIs
- 🔄 CRUD Operations - Create, read, update, delete
- 🔍 Query Builder - Build complex queries
- 📊 Data Binding - Automatic data synchronization
- 🚀 Real-time Updates - Live data updates (if supported by backend)
- ObjectStack Client Integration - Uses
@objectstack/clientfor API communication - Headless - No React dependency; the adapter is a plain
DataSourceobject you inject at the renderer boundary - Query Support - Full ObjectQL query support
- Automatic Field Mapping - Maps ObjectStack schemas to UI components
- Type Safety - Full TypeScript support
- Error Handling - Comprehensive error handling and retry logic
This package is headless — it exports no React components and no hooks. You create a plain adapter object:
import { createObjectStackAdapter } from '@object-ui/data-objectstack';
const dataSource = createObjectStackAdapter({
baseUrl: 'https://api.example.com',
token: 'your-api-token', // optional if auth is handled elsewhere
});createObjectStackAdapter returns an ObjectStackAdapter — the concrete adapter
class, which implements DataSource, the universal interface every ObjectUI
renderer consumes. new ObjectStackAdapter(config) is the class form of the same
thing and has the same type. Annotate the value as DataSource wherever you want
only the universal surface.
React wiring lives in @object-ui/react, not here. Wrap your tree in
SchemaRendererProvider and hand it the adapter:
import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
import { createObjectStackAdapter } from '@object-ui/data-objectstack';
import type { ObjectGridBlockNode } from '@object-ui/types';
const dataSource = createObjectStackAdapter({
baseUrl: 'https://api.example.com',
});
// The block's props go in its `properties` bag, as `@objectstack/spec` declares them.
const mySchema: ObjectGridBlockNode = { type: 'object-grid', properties: { objectName: 'user' } };
function App() {
return (
<SchemaRendererProvider dataSource={dataSource}>
<SchemaRenderer schema={mySchema} />
</SchemaRendererProvider>
);
}SchemaRenderer also accepts an explicit dataSource prop
(<SchemaRenderer schema={mySchema} dataSource={dataSource} />) when you would
rather inject per render than through context.
A schema node's dataSource key is metadata, not the adapter — it is the
spec's per-element binding describing what to query, while the adapter above
describes how to reach the backend:
{
"type": "list-view",
"dataSource": { "object": "user", "view": "active", "limit": 50 }
}The binding accepts object, view, filter, sort and limit. view names
a saved view whose columns, filter, sort and page size are applied to the render;
filter is additional criteria that AND-combines with the view's rather than
replacing it. See Per-element data binding for the
full table of which blocks honour which keys.
Factory returning an ObjectStackAdapter — the concrete adapter class, which
implements DataSource — backed by an ObjectStack backend.
Config:
{/* doc-snippet: fragment — a SIGNATURE excerpt of the factory: a function declaration with no body and an ObjectStackAdapter return type this reference block does not import (measured: TS2391 x1, TS2304 x1). packages/data-objectstack/dist/index.d.ts — and this reason may not claim it does (objectui#7505). Read the shipped declaration when this page matters to you; it has drifted twice, once in the return type (objectui#7503) and once in a config member (objectui#7505) */}
function createObjectStackAdapter<T = unknown>(config: {
/** ObjectStack server base URL */
baseUrl: string;
/** Optional bearer token */
token?: string;
/** Optional custom fetch (proxies, auth headers, test doubles) */
fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
/** Metadata cache tuning */
cache?: {
maxSize?: number; // default 100 schemas
ttl?: number; // default 5 * 60 * 1000 ms
};
/** Reconnect behaviour */
autoReconnect?: boolean; // default true
maxReconnectAttempts?: number; // default 3
reconnectDelay?: number; // default 1000 ms
}): ObjectStackAdapter<T>;Example:
import { createObjectStackAdapter } from '@object-ui/data-objectstack';
const dataSource = createObjectStackAdapter({
baseUrl: 'https://api.example.com',
token: 'your-api-token',
cache: { maxSize: 100, ttl: 5 * 60 * 1000 },
autoReconnect: true,
maxReconnectAttempts: 5,
});api.example.com is a placeholder — here and in every other example on this
page. RFC 2606 reserves example.com precisely so documentation can name a host
that can never resolve to somebody's real service, so a snippet copied with it
left in fails loudly instead of reaching a stranger's server. Replace it with
your own ObjectStack server's URL.
Pass a record type to get typed results — the default is unknown:
import { createObjectStackAdapter } from '@object-ui/data-objectstack';
type User = { id: string; name: string; email: string };
const dataSource = createObjectStackAdapter<User>({ baseUrl: 'https://api.example.com' });The class behind the factory — and the type the factory declares. new ObjectStackAdapter(config) and createObjectStackAdapter(config) take the same
config and produce the same type, so the two forms are interchangeable:
import { createObjectStackAdapter, ObjectStackAdapter } from '@object-ui/data-objectstack';
type User = { id: string; name: string; email: string };
// Same declared type, either way.
const fromFactory = createObjectStackAdapter<User>({ baseUrl: 'https://api.example.com' });
const fromClass = new ObjectStackAdapter<User>({ baseUrl: 'https://api.example.com' });What changed. This section used to tell you to hold the class type to
reach the members listed under Beyond DataSource below, because the factory
declared DataSource<T> while returning new ObjectStackAdapter(config): the
value always carried those members, only the declared type hid them
(objectui#7323). That distinction is gone. If you switched to
new ObjectStackAdapter(...) solely to reach a cache, connection-state or batch
method, you can switch back to the factory — nothing about the value changes, and
neither does its type. The class name is still worth importing when you need
something to annotate with.
Narrowing still works, and is still the right shape for a prop, a field or a test double that must accept any adapter:
import type { DataSource } from '@object-ui/types';
import { createObjectStackAdapter } from '@object-ui/data-objectstack';
// The universal surface only, by annotation.
const dataSource: DataSource = createObjectStackAdapter({ baseUrl: 'https://api.example.com' });On the DataSource interface (available from either form, and from any other
adapter):
find(resource, params?)- Query multiple recordsfindOne(resource, id, params?)- Get a single record by IDcreate(resource, data)- Create a recordupdate(resource, id, data, opts?)- Update a recorddelete(resource, id, opts?)- Delete a recordgetObjectSchema(objectName)- Fetch schema metadata (cached)bulk?(resource, operation, data)- Batch create/update/delete on one objectbatchTransaction?(operations)- Cross-object atomic batch (master-detail)onMutation?(listener)- Subscribe to create/update/delete eventsqueryGroupHeaders?(resource, query)- Answer a grouped list view's group header query (compileListViewGroupQuery), one row per group with its truecount— what a grouped grid groups by on the server (objectui#7189)
bulk, batchTransaction, onMutation and queryGroupHeaders are
optional members of DataSource: not every adapter implements them, so
through a DataSource-typed value they must be feature-detected
(typeof dataSource.bulk === 'function'). ObjectStackAdapter implements all
four unconditionally, and the factory declares the class, so a value from
either form calls them directly. (onMutation
was listed under Adapter-only here until objectui#7323; it is optional on
DataSource, not absent from it.)
Beyond DataSource (declared on ObjectStackAdapter, so reachable from
either form):
connect()- Establish the connection (called lazily by every operation)getCacheStats()/invalidateCache(key?)/clearCache()- Cache controlloadAppBundle(appId)- Load the app's metadata bundle (GET /meta/app/{appId}/bundle), or revalidate the one held withIf-None-Match; by-name metadata reads of the items it holds are answered from it (see App metadata bundle)getClient()- Access the underlying@objectstack/clientinstancegetConnectionState()/isConnected()- Connection introspectiononConnectionStateChange(listener)- Subscribe to state changes (returns unsubscribe)onBatchProgress(listener)- Subscribe to bulk progress (returns unsubscribe)
Those seven bullets cover ten members, and they are the documented subset.
ObjectStackAdapter declares more members beyond DataSource than these —
Exclude<keyof ObjectStackAdapter<unknown>, keyof DataSource<unknown>> lists
them all; the rest are lower-level seams — client discovery, cache-key
invalidation, dataset and dashboard access and advisory subscriptions among
them — which this page does not document and which carry no compatibility
promise here.
The console calls loadAppBundle(appName) when it enters an app. One request,
GET /api/v1/meta/app/{appId}/bundle, returns the app, the objects its
navigation reaches with the objects that reference them, and their views,
pages and dashboards. Each one is the envelope its own
GET /meta/{type}/{name} read would return to this caller.
- A by-name read of an item the bundle holds is answered from the bundle and
never sent:
getObjectSchema,getView,getPage,getApp,getItems, and the client'smeta.getItem(type, name)with no options. - An item the bundle does not hold keeps its by-name read. That covers objects outside the app, reads made with options, and an item withheld from the caller, which is absent from the bundle and gets its own read's refusal.
- Calling
loadAppBundleagain for the same app sendsIf-None-Matchwith the heldETagexactly as served (a weakW/validator included), and a304keeps the bundle already held. - A bundle served
no-storewith noETag(the server could not settle a member's field visibility) answers the reads that waited on it and is not kept. Later reads go by name until the app is entered again. - A metadata write through the client, or an
invalidateCache(key)that names a bundled item, makes the next bundled read revalidate the bundle first. So does an answer older than the cache TTL.clearCache()drops every bundle, and the next read downloads it again.
If the bundle is refused, or the server has no such route, every read stays on its by-name route.
A schema node's dataSource key is the spec's ElementDataSource (validated by
ElementDataSourceSchema in @objectstack/spec) — plain JSON metadata, not
the adapter object:
{/* doc-snippet: fragment — a SHAPE excerpt restating @objectstack/spec's own ElementDataSource as a bare interface, so its filter member names the spec's ViewFilterRule without importing it (measured: TS2304 x1); the type is exported from @objectstack/spec/ui. ElementDataSourceSchema in the installed @objectstack/spec — and this reason may not claim it does (objectui#7505). The schema is the authority on which members are required and whether it is strict; read it there */}
interface ElementDataSource {
/** Object to query (required) */
object: string
/** Saved view name — supplies columns, filter, sort and page size */
view?: string
/** Additional filter rules, one `{ field, operator, value }` per condition; they AND together and with the view's filter */
filter?: ViewFilterRule[]
/** Sort order — overrides the view's */
sort?: Array<{ field: string; order: 'asc' | 'desc' }>
/** Row cap — overrides the view's page size */
limit?: number
}The spec schema is strict: a key outside this list is rejected rather than
silently ignored. SchemaRenderer also strips this key from the props it
spreads, so the metadata can never shadow the injected adapter. Full per-block
support table: Per-element data binding.
Every example below binds a block with the per-element dataSource metadata. The
adapter itself is injected once, at the renderer boundary, as shown in Quick Start.
{
"type": "object-grid",
"dataSource": { "object": "product", "limit": 20 }
}filter AND-combines with the view's own filter, so a binding can only narrow
what the view already restricts. It is a list of { field, operator, value }
rules, which AND together:
{
"type": "object-grid",
"dataSource": {
"object": "order",
"view": "open_orders",
"filter": [{ "field": "total", "operator": "greater_than", "value": 100 }],
"limit": 50
}
}Form blocks honour object (a form edits a record, so there is no collection
query to filter or page):
{
"type": "object-form",
"dataSource": { "object": "user" }
}object-kanban honours object and filter; it has no ordering or row cap of
its own:
{
"type": "object-kanban",
"dataSource": {
"object": "task",
"filter": [{ "field": "project", "operator": "equals", "value": "acme" }]
}
}The adapter reads no environment variables of its own — configuration is
passed to createObjectStackAdapter in code. Sourcing those values from the
environment is your bundler's business (process.env.* under Node or webpack,
import.meta.env.* under Vite):
{/* doc-snippet: fragment — process is a NODE global carried by the reader's own @types/node, which this gate's program does not load, and the paragraph above says so in words: sourcing config from the environment is the reader's bundler's business (measured: TS2591 x2) */}
import { createObjectStackAdapter } from '@object-ui/data-objectstack';
const dataSource = createObjectStackAdapter({
baseUrl: process.env.OBJECTSTACK_API_URL!,
token: process.env.OBJECTSTACK_API_TOKEN,
cache: { maxSize: 200, ttl: 10 * 60 * 1000 },
autoReconnect: true,
maxReconnectAttempts: 5,
reconnectDelay: 2000,
});Pass your own fetch to route requests through a proxy or attach extra headers:
import { createObjectStackAdapter } from '@object-ui/data-objectstack';
const dataSource = createObjectStackAdapter({
baseUrl: 'https://api.example.com',
fetch: (input, init) =>
fetch(input, { ...init, headers: { ...init?.headers, 'X-Tenant': 'acme' } }),
});Call the adapter directly for queries a block does not cover. Query parameters
are OData-style ($filter, $orderby, $top, $skip, $select, $expand):
import { createObjectStackAdapter } from '@object-ui/data-objectstack';
const dataSource = createObjectStackAdapter({ baseUrl: 'https://api.example.com' });
const startDate = '2024-01-01';
const [users, orders] = await Promise.all([
dataSource.find('user', {
$filter: { createdAt: { $gte: startDate } },
$orderby: 'createdAt desc',
$top: 20,
}),
dataSource.find('order', {
$filter: { status: 'completed' },
$select: ['id', 'total', 'createdAt'],
}),
]);
// find() resolves to { data, total? }
console.log(users.data.length, users.total);import { createObjectStackAdapter } from '@object-ui/data-objectstack';
type User = { id: string; name: string; email: string };
const dataSource = createObjectStackAdapter<User>({ baseUrl: 'https://api.example.com' });
const user = await dataSource.create('user', { name: 'Ada', email: 'ada@example.com' });
const updated = await dataSource.update('user', user.id, { name: 'Ada Lovelace' });
await dataSource.delete('user', user.id);Batch writes on one object go through bulk, and cross-object writes that must
commit or roll back together go through batchTransaction. Both are optional on
the DataSource interface, so a value you annotated as DataSource still has to
feature-detect them — but the adapter implements both unconditionally and the
factory declares the adapter, so the value from Quick Start calls them directly:
import { createObjectStackAdapter } from '@object-ui/data-objectstack';
type User = { id: string; name: string; email: string };
const adapter = createObjectStackAdapter<User>({ baseUrl: 'https://api.example.com' });
await adapter.bulk('user', 'create', [
{ name: 'Alice', email: 'alice@example.com' },
{ name: 'Bob', email: 'bob@example.com' },
]);
// `{ $ref: 0 }` resolves to the id minted by operation 0
await adapter.batchTransaction([
{ object: 'invoice', action: 'create', data: { no: 'INV-1' } },
{ object: 'invoice_line', action: 'create', data: { invoice: { $ref: 0 }, amount: 10 } },
]);The adapter throws typed errors with stable codes:
import {
createObjectStackAdapter,
MetadataNotFoundError,
AuthenticationError,
ConnectionError,
} from '@object-ui/data-objectstack';
const dataSource = createObjectStackAdapter({ baseUrl: 'https://api.example.com' });
try {
const schema = await dataSource.getObjectSchema('user');
} catch (error) {
if (error instanceof MetadataNotFoundError) {
// code 'METADATA_NOT_FOUND', statusCode 404
} else if (error instanceof AuthenticationError) {
// code 'AUTHENTICATION_ERROR', statusCode 401
} else if (error instanceof ConnectionError) {
// code 'CONNECTION_ERROR', statusCode 503
}
}Exported error types: ObjectStackError (base), MetadataNotFoundError,
BulkOperationError, ConnectionError, AuthenticationError,
DataApiValidationError, plus the isObjectStackError / isErrorType guards.
Package Name: @object-ui/data-objectstack — published on npm, see the
npm page for the current version
License: MIT
This package declares no peer dependencies. Everything below is a regular dependency that is installed and resolved with the package:
- @objectstack/client - ObjectStack API client
- @objectstack/spec - ObjectStack metadata spec and shared contracts
- @object-ui/core - Schema engine and query helpers
- @object-ui/types - TypeScript types
Many plugins support ObjectStack data sources:
- plugin-grid - Data grids with ObjectStack queries
- plugin-form - Forms with ObjectStack CRUD
- plugin-kanban - Kanban boards with ObjectStack data
- plugin-calendar - Calendars with ObjectStack events
- plugin-gantt - Gantt charts with ObjectStack tasks
- plugin-map - Maps with ObjectStack locations
- ObjectStack Documentation - Learn about ObjectStack
- Data Sources Guide - Learn about data sources
- Plugins - Explore plugins with data support
- Schema Overview - Learn the schema format
An AuthenticationError (code AUTHENTICATION_ERROR, status 401) means the
token passed to createObjectStackAdapter was missing, expired or rejected.
Check the connection state and the values you passed in:
Connection introspection lives on the adapter, not on the DataSource interface
— and the factory declares the adapter, so the value from Quick Start reaches it
without a second construction:
import { createObjectStackAdapter } from '@object-ui/data-objectstack';
const dataSource = createObjectStackAdapter({ baseUrl: 'https://api.example.com' });
console.log(dataSource.getConnectionState()); // 'connected' | 'error' | ...
dataSource.onConnectionStateChange((event) => {
if (event.error) console.error('Connection error:', event.error);
});After the first connect, the state follows the requests you make. A request
with no HTTP answer moves it to reconnecting, then to error once the
maxReconnectAttempts budget is spent. The next answered request moves it back
to connected. An answered 4xx or 5xx leaves it unchanged, because the server
is reachable.
Configure CORS on your ObjectStack backend to allow your domain.
Check browser console and network tab for errors. Verify:
baseUrlpoints at the right ObjectStack server- The token is valid and not expired
- The object name exists in your ObjectStack instance
- The block's
dataSourcebinding names an object (and aview, if used) that resolves - An adapter is actually injected — a block with no
SchemaRendererProviderabove it and nodataSourceprop has nothing to query with