Official ObjectStack data adapter for Object UI.
This package provides the ObjectStackAdapter class, which connects Object UI's universal DataSource interface with the @objectstack/client SDK.
This enables strictly typed, metadata-driven UI components to communicate seamlessly with ObjectStack backends (Steedos, Salesforce, etc.).
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.
import { createObjectStackAdapter } from '@object-ui/data-objectstack';
import { SchemaRenderer } from '@object-ui/react';
import type { DeclaredNode } from '@object-ui/types';
declare const mySchema: DeclaredNode;
// 1. Create the adapter
const dataSource = createObjectStackAdapter({
baseUrl: 'https://api.example.com',
token: 'your-api-token' // Optional if effectively handling auth elsewhere
});
// 2. Pass to the Renderer
function App() {
return (
<SchemaRenderer
schema={mySchema}
dataSource={dataSource}
/>
);
}import { createObjectStackAdapter } from '@object-ui/data-objectstack';
const dataSource = createObjectStackAdapter({
baseUrl: 'https://api.example.com',
token: 'your-api-token',
// Configure metadata cache
cache: {
maxSize: 100, // Maximum number of cached schemas (default: 100)
ttl: 5 * 60 * 1000 // Time to live in ms (default: 5 minutes)
},
// Configure auto-reconnect
autoReconnect: true, // Enable auto-reconnect (default: true)
maxReconnectAttempts: 5, // Max reconnection attempts (default: 3)
reconnectDelay: 2000 // Initial delay between reconnects in ms (default: 1000)
});- ✅ CRUD Operations: Implements
find,findOne,create,update,delete. - ✅ Shared In-Flight Reads: Concurrent
findcalls with the same resource and params, and concurrentfindOnecalls with the same resource, id and params, share one request. The entry is dropped when the read settles, so a later call reads again (this is not a response cache), and a failed read is not remembered. A write through the adapter drops the pendingfindOnereads of the resource it wrote, so a read asked after a save never gets an answer sent before it.MetadataClientshares itslistTypes,list,listDrafts,getandgetDraftreads the same way. Calls with the same method, URL and headers on clients built with the samefetchshare one request, and every caller gets an answer no other caller holds. A save, publish or reset through any of those clients drops the reads still pending. - ✅ Metadata Caching: Automatic LRU caching of schema metadata with TTL expiration.
- ✅ Metadata Fetching: Implements
getObjectSchemato power auto-generated forms and grids. - ✅ Query Translation: Converts Object UI's OData-like query parameters to ObjectStack's native query format.
- ✅ Bulk Operations: Supports optimized batch create/update/delete with detailed error reporting.
- ✅ Error Handling: Comprehensive error hierarchy with unique error codes and debugging details.
- ✅ Connection Monitoring: Real-time connection state tracking with event listeners.
- ✅ Auto-Reconnect: Automatic reconnection with exponential backoff on connection failures.
- ✅ Batch Progress: Progress events for tracking bulk operation status.
find() accepts Object UI's OData-style QueryParams and translates them into
ObjectStack's native query format, so a schema never has to be written in the
protocol's own shape:
import type { ObjectStackAdapter } from '@object-ui/data-objectstack';
declare const dataSource: ObjectStackAdapter;
// Query with filters (MongoDB-like operators)
const result = await dataSource.find('tasks', {
$filter: {
status: 'active',
priority: { $gte: 2 },
},
$orderby: { createdAt: 'desc' },
$top: 20,
$skip: 0,
});
// Escape hatch: reach the underlying ObjectStack client for anything
// the DataSource interface does not cover
const client = dataSource.getClient();
const metadata = await client.meta.getItem('object', 'task');Object UI ($) |
ObjectStack | Description |
|---|---|---|
$select |
select |
Field selection |
$filter |
filters (AST) |
Filter conditions (converted to FilterNode AST) |
$orderby |
sort |
Sort order |
$skip |
skip |
Pagination offset |
$top |
top |
Limit records |
The adapter converts MongoDB-like filter operators into ObjectStack FilterNode AST format. This is what keeps it compatible with the ObjectStack Protocol (v0.1.2+).
Every row below is decided by @object-ui/core's convertFiltersToAST, and
this package's src/readme-filter-operator-table.test.ts runs each worked
example through it on every test run, so a row cannot drift from the code
unnoticed again (objectui#8558). Every spelling below is the spec's own
(FILTER_OPERATORS in @objectstack/spec's data/filter.zod.ts) and there are
no aliases: the four lowercase spellings this table used to list beside the
camelCase keys — $notin, $notcontains, $startswith, $endswith — were
retired by objectui#8568 and moved to the refused table below.
$icontains is the case-insensitive member of the $contains family, and its
ObjectStack spelling is the SAME word: icontains is itself a member of the
spec's VALID_AST_OPERATORS, so nothing is squashed on the way down. It had no
row here at all until objectui#8976 — convertFiltersToAST refused it as an
unknown operator while ValueDataSource executed it and this repo's own filter
builder emitted it. The pin now holds these two tables complete against the
spec's FILTER_OPERATORS as well as against the code, so a canonical operator
that is documented NEITHER as supported NOR as refused fails the suite instead
of going unnoticed.
| MongoDB Operator | ObjectStack Operator | Example |
|---|---|---|
| plain value (no operator) | = |
{ status: 'active' } → ['status', '=', 'active'] |
$eq |
= |
{ status: { $eq: 'active' } } → ['status', '=', 'active'] |
$ne |
!= |
{ status: { $ne: 'archived' } } → ['status', '!=', 'archived'] |
$gt |
> |
{ age: { $gt: 18 } } → ['age', '>', 18] |
$gte |
>= |
{ age: { $gte: 18 } } → ['age', '>=', 18] |
$lt |
< |
{ age: { $lt: 65 } } → ['age', '<', 65] |
$lte |
<= |
{ age: { $lte: 65 } } → ['age', '<=', 65] |
$in |
in |
{ status: { $in: ['active', 'pending'] } } → ['status', 'in', ['active', 'pending']] |
$nin |
nin |
{ status: { $nin: ['archived'] } } → ['status', 'nin', ['archived']] |
$between |
between |
{ age: { $between: [18, 65] } } → ['age', 'between', [18, 65]] |
$contains |
contains |
{ name: { $contains: 'John' } } → ['name', 'contains', 'John'] |
$notContains |
notcontains |
{ name: { $notContains: 'test' } } → ['name', 'notcontains', 'test'] |
$startsWith |
startswith |
{ email: { $startsWith: 'admin' } } → ['email', 'startswith', 'admin'] |
$endsWith |
endswith |
{ email: { $endsWith: '@example.com' } } → ['email', 'endswith', '@example.com'] |
$icontains |
icontains |
{ name: { $icontains: 'john' } } → ['name', 'icontains', 'john'] |
$null |
is_null / is_not_null |
{ email: { $null: true } } → ['email', 'is_null', true] |
$exists |
is_not_null / is_null |
{ email: { $exists: true } } → ['email', 'is_not_null', true] |
$empty |
is_empty / is_not_empty |
{ email: { $empty: true } } → ['email', 'is_empty', true] |
$null, $exists and $empty read their boolean: $null: false lowers to
is_not_null, $exists: false to is_null and $empty: false to
is_not_empty. The lowered node's value slot is always true — the direction
comes from the operator name, which is how the spec's data/filter.zod.ts
reads it. $empty takes ONLY a boolean: any other flag throws INVALID_FILTER
/ 400 at lowering time, which is how the spec's own doors treat it.
$empty is not $null. objectstack#20446 admitted it to the spec's
FILTER_OPERATORS and flipped the view operators is_empty / is_not_empty
to lower to it; they lowered to $null before. Its meaning is the spec's ruled
per-type table: a text-like field is empty when null or '', a multi-value
field when null or [], and any other field when null. Which backend applies
that table, and how, is listed in the docblock of the spec's
FILTER_OPERATORS, not here. Until objectui#11094 convertFiltersToAST
refused $empty as an unknown operator.
$icontains constrains its comparand, which no other row in this table
does: @objectstack/spec's FILTER_TEXT_CASES declares an empty or non-string
comparand REFUSED, so an empty box or an uncoerced number throws
INVALID_FILTER / 400 at lowering time instead of sending a predicate that
constrains nothing or a question nobody wrote (objectui#9001). The operator is
supported exactly as the row says — write a non-empty string, or drop the
condition.
$and and $or take an array of filter conditions and lower to an AST group
node whose members are lowered with the table above. Sibling keys of one
object are still combined with and (see "Complex Filter Examples" below). An
empty $and is TRUE and is dropped rather than emitted as an empty group —
['and'] is not a filter, so emitting it would widen the result set — and an
empty $or is FALSE and matches no row.
| MongoDB Key | AST Keyword | Example |
|---|---|---|
$and |
and |
{ $and: [{ status: 'open' }, { priority: { $gte: 2 } }] } → ['and', ['status', '=', 'open'], ['priority', '>=', 2]] |
$or |
or |
{ $or: [{ status: 'open' }, { status: 'blocked' }] } → ['or', ['status', '=', 'open'], ['status', '=', 'blocked']] |
These shapes throw a FilterOperatorError (code: 'INVALID_FILTER',
httpStatus: 400) from convertFiltersToAST instead of reaching the wire. The
refusal names the field and the value, so a filter that carries one fails at
the call site rather than as a 400 from the server or as an empty list.
| Shape | Why | Example |
|---|---|---|
$regex |
The spec has no $regex, and it is not downgraded to contains: a pattern match and a substring match are different questions, not stronger and weaker forms of one. Use $contains for a case-sensitive substring, $icontains for a case-insensitive one, or $startsWith / $endsWith. |
{ name: { $regex: '^J' } } → throws INVALID_FILTER |
$not |
The AST has no negation keyword, and rewriting the negation inward would be silently partial. Use a negated operator instead: $ne, $nin, $notContains. |
{ $not: { status: 'open' } } → throws INVALID_FILTER |
| a bare array as a field's value | The AST has no array-equality node, and the array is deliberately not read as $in (see below). |
{ tags: ['a', 'b'] } → throws INVALID_FILTER |
$notin / $notcontains / $startswith / $endswith |
Retired lowercase aliases (objectui#8568). The $ dialect follows @objectstack/spec's spellings, and this repo's in-memory matcher already refused these; accepting them here made one authored filter behave differently depending on the data source behind the view. The refusal names the canonical spelling for the alias you wrote — rename the key, the operator is unchanged. |
{ email: { $startswith: 'a' } } → throws INVALID_FILTER |
any other $ key in operator position |
Unknown operator; the error message lists the supported ones. | { age: { $foo: 1 } } → throws INVALID_FILTER |
A bare array as a field's value — { tags: ['a', 'b'] } — is refused at
lowering time with a FilterOperatorError (INVALID_FILTER / 400) naming the
field and the comparand, not read as $in (objectui#8530): the ObjectQL AST
has no array-equality node, so ['tags', '=', ['a', 'b']] was answered by a
400 from the wire or an empty list from every in-memory matcher. Spell
membership as { tags: { $in: ['a', 'b'] } } (or $nin for its negation).
An exotic object as a field's value — a RegExp, a Set, a Map, a URL,
any class instance — is refused the same way (objectui#8567). It has no row in
the table above because the table's examples are executed as JSON literals and
none of these shapes can be written as one; the refusal is no less real for
that. { name: /abc/ } used to lower to nothing at all: Object.entries of
a RegExp is [], so the operator loop ran zero times and the name condition
was dropped from the filter entirely, silently widening the result set. The spec
rules these out — a comparison value must be a string, number, bigint, boolean,
null or Date (ACCEPTED_FILTER_COMPARAND_TYPES), and
normalizeFilterComparandTypes answers 400 INVALID_FILTER for a RegExp —
so the refusal now lands at lowering time instead of the field vanishing. Spell
a text match as { name: { $contains: 'abc' } } ($startsWith / $endsWith), a
membership test as { name: { $in: [...] } }, and a date bound as
{ created: { $gte: someDate } }. A Date comparand is not exotic: the spec
accepts it and it lowers as an equality node (objectui#8555). An empty
operator object — { created: {} }, a field with no operator — is refused too,
alone or beside other keys, with a FilterOperatorError naming the field
(objectui#9164 alone, objectui#10788 beside other keys). @objectstack/spec records
{ field: {} } as REJECTED (objectstack#5240), and beside a key that lowers it
used to be dropped the same silent way: { status: 'a', created: {} } sent
exactly what { status: 'a' } sends. Give the field an operator ($eq, $in,
$null, …) or remove the key.
Multiple conditions are combined with 'and':
// Input
const $filter = {
age: { $gte: 18, $lte: 65 },
status: 'active',
};
// Converted to AST
const ast = [
'and',
['age', '>=', 18],
['age', '<=', 65],
['status', '=', 'active'],
];Server-driven view configs store their conditions as an array of rules
(ViewFilterRule[]), not as a MongoDB-style object:
const filter = [{ field: 'stage', operator: 'equals', value: 'won' }];Both read paths lower that array to the same AST before it reaches the wire —
find() via $filter, and aggregate() via the analytics where. They share
one translator, so a stored filter cannot mean one thing on a list and another
on a KPI:
// find(): filter=["stage","=","won"]
// aggregate(): { ..., where: ["stage", "=", "won"] }Operator aliases (equals, greater_than_or_equal, not_in, before, ...)
map to the canonical AST symbols, and rules spread into a logical node
(['and', ...rules, ...tuples]) are lowered at depth. A rule that cannot be
translated raises MalformedFilterError rather than being dropped — dropping
one condition of an and would widen the result set and report success.
An icontains rule constrains its comparand here exactly as $icontains
does in the object form: @objectstack/spec's FILTER_TEXT_CASES declares an
empty or non-string comparand REFUSED, so a rule such as
{ field: 'name', operator: 'icontains', value: '' } — or one whose value is
a number, null or missing — throws MalformedFilterError (INVALID_FILTER /
400) before any request is sent, instead of sending a predicate that constrains
nothing or a question nobody wrote (objectui#9048). The reason is the spec's
own, the one the object form's refusal carries (objectui#9001), and the message
names the operator spelling that arrived (icontains, ICONTAINS, …) beside
the $icontains spelling the contract uses. The rule is refused wherever it
sits — at the top level, in a nested group, or spread into a logical node — and
on both find() and aggregate(). Write a non-empty string, or drop the rule.
Non-array filters are passed through unchanged on the aggregate path: a
MongoDB-style object is already what /analytics/query accepts.
The lowering above is the filter parameter, on the analytics path.
aggregate() has a second, spec-shape branch — entered when the params carry
an array groupBy, an array aggregations, or any where key — which
posts where to POST /data/:object/query verbatim. That where is the spec
Query DSL's where, so it must ALREADY be lowered, and since objectui#6825 an
array that the spec's own isFilterAST gate rejects is refused here rather
than shipped:
import type { ObjectStackAdapter } from '@object-ui/data-objectstack';
declare const dataSource: ObjectStackAdapter;
// ✅ lowered — reaches the wire unchanged
await dataSource.aggregate('opportunity', {
groupBy: ['stage'],
aggregations: [{ function: 'count', field: 'id', alias: 'n' }],
where: ['stage', '=', 'won'],
});
// ⛔ throws UnloweredAggregateWhereError — authoring sugar, not a filter
await dataSource.aggregate('opportunity', {
groupBy: ['stage'],
aggregations: [{ function: 'count', field: 'id', alias: 'n' }],
where: [{ field: 'stage', operator: 'equals', value: 'won' }],
});This is a producer-side refusal of a value the server already refused (is not a filter, 400 INVALID_FILTER): the point is that you now find out at the call
site, with the offending value named, instead of on the wire — or not at all,
with a chart quietly rendering unfiltered numbers. Lower the rules in whatever
built the params, or use the analytics branch (filter + the legacy field /
function / groupBy params), which lowers for you.
Two shapes are deliberately NOT refused, because the receiving door accepts
them: a FilterCondition object ({ stage: 'won' } — what QuerySchema.where
declares), and an empty array ([] means "no filter").
The spec-shape branch reads exactly four keys — groupBy, aggregations,
where, limit — and filter / field / function are the analytics
branch's own parameters. Until objectui#6864 they were neither read nor
reported on the spec-shape branch: they simply were not in the body that went
out. Since #6864 they are refused, by name:
import type { ObjectStackAdapter } from '@object-ui/data-objectstack';
declare const dataSource: ObjectStackAdapter;
// ⛔ throws AnalyticsKeysOnSpecShapeError — an ARRAY `groupBy` selects the
// spec-shape branch, and these three keys would have vanished from the query:
// no filter, and no measure at all.
await dataSource.aggregate('opportunity', {
field: 'amount',
function: 'sum',
groupBy: ['stage'],
filter: [{ field: 'stage', operator: 'equals', value: 'won' }],
});
// ✅ the analytics shape, unchanged — a STRING `groupBy` keeps this call on the
// analytics branch, where all three keys are read and `filter` is lowered for you
await dataSource.aggregate('opportunity', {
field: 'amount',
function: 'sum',
groupBy: 'stage',
filter: [{ field: 'stage', operator: 'equals', value: 'won' }],
});Pick one shape per call. A key that is present but nullish (filter: undefined)
carries nothing to drop and is not refused, so params built by spreading
possibly-absent authored values keep working. Keys outside those three are not
refused either — the gate names filter, field and function, and only
those.
queryGroupHeaders(resource, query) answers a grouped list view's group
header query — the EngineAggregateOptions that @objectstack/spec/ui's
compileListViewGroupQuery compiles from the view's grouping (and its column
summaries). It is how a grouped object-grid gets its group set and every
header number from the server instead of bucketing a fetched page
(objectui#7189): one row per group, each grouped field under its own name
holding the raw stored value (null for the empty group), and the group's
total row count under count.
The query is posted verbatim to the existing POST /data/:object/query
door (the same one the spec-shape aggregate() branch uses), and the answer is
its records. Unlike aggregate(), it never degrades to a client-side
reduction: a refusal is thrown with the server's code and status, and an answer
without a records array is refused rather than read as "no groups". The rows
inside a group are an ordinary find() with the group's compiled row query
(compileListViewGroupRowsQuery).
import type { ObjectStackAdapter } from '@object-ui/data-objectstack';
declare const dataSource: ObjectStackAdapter;
// OData-style
await dataSource.find('users', {
$orderby: {
createdAt: 'desc',
name: 'asc',
},
});
// Converted to ObjectStack: ['-createdAt', 'name']The adapter includes built-in metadata caching to improve performance when fetching schemas:
import { createObjectStackAdapter } from '@object-ui/data-objectstack';
const dataSource = createObjectStackAdapter({ baseUrl: 'https://api.example.com' });
// Get cache statistics
const stats = dataSource.getCacheStats();
console.log(`Cache hit rate: ${stats.hitRate * 100}%`);
console.log(`Cache size: ${stats.size}/${stats.maxSize}`);
console.log(`Fetches coalesced onto an in-flight request: ${stats.coalesced}`);
// Manually invalidate cache entries
dataSource.invalidateCache('users'); // Invalidate specific schema
dataSource.invalidateCache(); // Invalidate all cached schemas
// Clear cache and statistics
dataSource.clearCache();- LRU Eviction: Automatically evicts least recently used entries when cache is full
- TTL Expiration: Entries expire after the configured time-to-live from creation (default: 5 minutes)
- Note: TTL is fixed from creation time, not sliding based on access
- Memory Limits: Configurable maximum cache size (default: 100 entries)
- Request Coalescing: Concurrent
getcalls for the same uncached key share a single fetch. The first caller invokes the fetcher; every caller that arrives while that promise is still in flight is handed the same promise instead of starting a second request, and each one increments thecoalescedcounter ingetCacheStats()— so the saving is something you can read off the adapter, not just a claim in this page.- The in-flight slot is released in a
finally, so a rejected fetch is not cached and does not poison the key: the next call starts a fresh fetch.
- The in-flight slot is released in a
The adapter provides real-time connection state monitoring with automatic reconnection:
import { createObjectStackAdapter } from '@object-ui/data-objectstack';
const dataSource = createObjectStackAdapter({ baseUrl: 'https://api.example.com' });
// Monitor connection state changes
const unsubscribe = dataSource.onConnectionStateChange((event) => {
console.log('Connection state:', event.state);
console.log('Timestamp:', new Date(event.timestamp));
if (event.error) {
console.error('Connection error:', event.error);
}
});
// Check current connection state
console.log(dataSource.getConnectionState()); // 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'error'
// Check if connected
if (dataSource.isConnected()) {
console.log('Adapter is connected');
}
// Unsubscribe from events when done
unsubscribe();disconnected- Not connected to serverconnecting- Attempting initial connectionconnected- Successfully connectedreconnecting- Attempting to reconnect after failureerror- Connection failed (check event.error for details)
The adapter automatically attempts to reconnect on connection failures:
- Exponential Backoff: Delay increases with each attempt (delay × 2^(attempts-1))
- Configurable Attempts: Set
maxReconnectAttempts(default: 3) - Configurable Delay: Set
reconnectDelayfor initial delay (default: 1000ms) - Automatic: Enabled by default, disable with
autoReconnect: false
Track progress of bulk operations in real-time:
import { createObjectStackAdapter } from '@object-ui/data-objectstack';
const dataSource = createObjectStackAdapter({ baseUrl: 'https://api.example.com' });
declare const largeDataset: Array<Record<string, unknown>>;
// Monitor batch operation progress
const unsubscribe = dataSource.onBatchProgress((event) => {
console.log(`${event.operation}: ${event.percentage.toFixed(1)}%`);
console.log(`Completed: ${event.completed}/${event.total}`);
console.log(`Failed: ${event.failed}`);
});
// Perform bulk operation
const users = await dataSource.bulk('users', 'create', largeDataset);
// Unsubscribe when done
unsubscribe();operation- Operation type ('create' | 'update' | 'delete')total- Total number of itemscompleted- Number of successfully completed itemsfailed- Number of failed itemspercentage- Completion percentage (0-100)
The adapter provides a comprehensive error hierarchy for better error handling:
import {
ObjectStackError, // Base error class
MetadataNotFoundError, // Schema/metadata not found (404)
BulkOperationError, // Bulk operation failures with partial results
ConnectionError, // Network/connection errors (503/504)
AuthenticationError, // Authentication failures (401/403)
DataApiValidationError, // Data validation errors (400). Its runtime `name` is
// still 'ValidationError' — that string is the wire
// discriminator shared with @objectstack/client. The
// SYMBOL is prefixed because @objectstack/spec/kernel
// owns `ValidationError` for a { field, message, code? }
// record (objectui#3160).
MalformedFilterError, // A filter rule that cannot be translated (400
// INVALID_FILTER) — thrown rather than dropped,
// because dropping one condition of an `and` widens
// the result set and reports success. Also thrown,
// before any request, for an `icontains` rule whose
// `value` is empty or not a string, naming the
// spelling that arrived. See "Rule-shaped arrays".
UnloweredAggregateWhereError, // aggregate()'s spec-shape `where` was an array
// the spec's filter-AST gate rejects (400
// INVALID_FILTER). See "aggregate({ where }) does
// NOT lower" above.
AnalyticsKeysOnSpecShapeError, // aggregate()'s spec-shape branch was handed the
// ANALYTICS branch's keys (`filter` / `field` /
// `function`), which it does not read (400
// INVALID_FILTER). See "the two shapes are
// alternatives, not a mixture" above.
isMalformedFilterError, // Recognises ALL THREE of the above, and the server's
// own version of the same refusal.
} from '@object-ui/data-objectstack';import {
ObjectStackError,
MetadataNotFoundError,
ConnectionError,
AuthenticationError,
type ObjectStackAdapter,
} from '@object-ui/data-objectstack';
declare const dataSource: ObjectStackAdapter;
try {
const schema = await dataSource.getObjectSchema('users');
} catch (error) {
if (error instanceof MetadataNotFoundError) {
console.error(`Schema not found: ${error.details?.objectName}`);
} else if (error instanceof ConnectionError) {
console.error(`Connection failed to: ${error.url}`);
} else if (error instanceof AuthenticationError) {
console.error('Authentication required');
}
// Every error this adapter throws carries the same shape
if (error instanceof ObjectStackError) {
console.error({
code: error.code,
message: error.message,
statusCode: error.statusCode,
details: error.details,
});
}
}Bulk operations provide detailed error reporting with partial success information:
import { BulkOperationError, type ObjectStackAdapter } from '@object-ui/data-objectstack';
declare const dataSource: ObjectStackAdapter;
declare const records: Array<Record<string, unknown>>;
try {
await dataSource.bulk('users', 'update', records);
} catch (error) {
if (error instanceof BulkOperationError) {
const summary = error.getSummary();
console.log(`${summary.successful} succeeded, ${summary.failed} failed`);
console.log(`Failure rate: ${summary.failureRate * 100}%`);
// Inspect individual failures
summary.errors.forEach(({ index, error }) => {
console.error(`Record ${index} failed:`, error);
});
}
}All errors include unique error codes for programmatic handling:
METADATA_NOT_FOUND- Schema/metadata not foundBULK_OPERATION_ERROR- Bulk operation failureCONNECTION_ERROR- Connection/network errorAUTHENTICATION_ERROR- Authentication failureVALIDATION_ERROR- Data validation errorINVALID_FILTER- A request the adapter refuses to send (MalformedFilterError,UnloweredAggregateWhereError,AnalyticsKeysOnSpecShapeError); matches the data API's own code for the same refusalUNSUPPORTED_OPERATION- Unsupported operationNOT_FOUND- Resource not foundUNKNOWN_ERROR- Unknown error
The adapter supports optimized batch operations with automatic fallback:
import type { ObjectStackAdapter } from '@object-ui/data-objectstack';
declare const dataSource: ObjectStackAdapter;
// Batch create
const newUsers = await dataSource.bulk('users', 'create', [
{ name: 'Alice', email: 'alice@example.com' },
{ name: 'Bob', email: 'bob@example.com' },
]);
// Batch update (uses updateMany if available, falls back to individual updates)
const updated = await dataSource.bulk('users', 'update', [
{ id: '1', name: 'Alice Smith' },
{ id: '2', name: 'Bob Jones' },
]);
// Batch delete
await dataSource.bulk('users', 'delete', [
{ id: '1' },
{ id: '2' },
]);- Automatically uses
createMany,updateMany,deleteManywhen available - Falls back to individual operations with detailed error tracking
- Provides partial success reporting for resilient error handling
- Atomic operations where supported by the backend
bulk() above operates on one object. To persist a set of cross-object
writes as a single all-or-nothing unit — the master-detail case, where a parent
and its children must commit or roll back together — use batchTransaction:
import type { ObjectStackAdapter } from '@object-ui/data-objectstack';
declare const dataSource: ObjectStackAdapter;
// Create a parent and a child that references it, atomically.
// `{ $ref: 0 }` resolves to the id minted by operation 0 (the parent).
await dataSource.batchTransaction([
{ object: 'invoice', action: 'create', data: { no: 'INV-1' } },
{ object: 'invoice_line', action: 'create', data: { invoice: { $ref: 0 }, amount: 10 } },
]);On a supporting backend this is one POST /api/v1/batch that commits or rolls
back the whole set in a single server transaction — no orphaned parent if a
child write fails.
Whether the adapter can rely on server atomicity is decided at connect time,
not by firing a batch and reading the failure. connect() reads the
capabilities.transactionalBatch flag from GET /api/v1/discovery
(framework #3298),
which the server sets to true only when the /batch route is mounted and
the runtime engine can honour a transaction (declared === enforced):
Discovery capabilities.transactionalBatch |
batchTransaction behaviour |
|---|---|
true |
Trusts server atomicity. Calls /batch; any failure — including 404/405/501 — surfaces as a real error. No non-atomic client-side fallback. |
false |
Backend can't do an atomic batch (route absent, or a runtime without transactions) → falls back to the non-atomic client-side emulation below. |
| absent | Backend predates #3298 and advertises nothing → the legacy runtime probe stays: try /batch, and on 404/405/501 fall back to emulation. |
The hierarchical wire shape ({ transactionalBatch: { enabled: true } }) and the
flat form the client SDK normalizes to ({ transactionalBatch: true }) are both
accepted.
When the capability is false or absent, the adapter degrades to a client-side
emulation (@object-ui/core's emulateBatchTransaction): the operations run in
order and, on failure, it best-effort deletes the records it created (children
before parent) before rethrowing. This is not a transaction — a create's
side effects (hooks, rollups, webhooks) are not undone by a later delete, and a
mid-batch network drop leaves no chance to compensate. It exists only so a save
is still possible against a backend that lacks server atomicity; removing it
would turn "saves, less safe" into "no save path" on older backends
(objectui #2679).
Atomic cross-object saves are guaranteed only against ObjectStack backends on
the 16.x line that advertise capabilities.transactionalBatch: true — the
endpoint landed in framework #1604
and its discovery capability in
framework #3298.
ObjectUI does not hard-require it: against an older backend a master-detail save
still succeeds, but non-atomically via the fallback above. Treat the advertised
capability as the floor for the atomicity guarantee, not as a connection
prerequisite.
MetadataClient.save refuses an object document whose fields carry either
of two incomplete fields, before issuing the request:
- a relationship field (
lookup,master_detail) with a missing, empty or whitespace-onlyreference; - a choice field (
select,radio) with no option source: neither a non-emptyoptionslist nor a sharedpicklist.options: []counts as none.
import { MetadataClient } from '@object-ui/data-objectstack';
const client = new MetadataClient({ baseUrl: '/api/v1' });
await client.save('object', 'account', {
name: 'account',
fields: { owner: { type: 'lookup', label: 'Owner' } },
});
// throws: The object was not saved: the field
// `owner` is a `lookup` and carries no `reference` key at all ...For the relationship rule, nothing that previously succeeded now fails.
@objectstack/spec refuses the same document at the server with a 422 on
fields.owner.reference, and that refusal blocks every later save of the
object for as long as the half-filled field rides along in the draft. The guard
moves the identical refusal earlier, names the field while it is still on screen,
and leaves the draft in the client.
The choice rule is different, and on purpose: it holds a document the installed
server still stores. It is the objectui half of the maintainer's ruling A on
objectstack#20827,
which refuses such a field at the FieldSchema door and has objectui stop
sending it first. A select added in Studio, and saved before its first option,
now waits in the client, with the field named, until it has one:
import { MetadataClient } from '@object-ui/data-objectstack';
const client = new MetadataClient({ baseUrl: '/api/v1' });
await client.save('object', 'deal', {
name: 'deal',
fields: { stage: { type: 'select', label: 'Stage', options: [] } },
});
// throws: The object was not saved: the field
// `stage` is a `select` with no options ...Writes of every other metadata type are untouched, and the guard never strips the offending field — a dropped field reported as saved would be a silent deletion.
Hosts that write object metadata through their own transport can apply the same invariant at their own door:
import { assertObjectMetadataWritable } from '@object-ui/data-objectstack';
async function uploadObject(name: string, body: unknown) {
assertObjectMetadataWritable('object', body, 'uploadObject');
await fetch(`/api/v1/meta/object/${encodeURIComponent(name)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}RELATIONSHIP_TYPES_REQUIRING_REFERENCE, CHOICE_TYPES_REQUIRING_OPTIONS and
OBJECT_METADATA_TYPE are exported beside it. Both type sets are derived from
the installed @objectstack/spec by this package's own pin (the choice set from
its field/choice-without-options completeness rule), so they follow the
contract rather than a remembered list.
When the server's spec check refuses a metadata save, it answers 422 INVALID_METADATA. The error's message is a headline: a count of issues and
where they are. What the author should do is in the structured issues, which
MetadataClient puts on the thrown error's issues. Show the error through
formatMetadataError, not through message alone:
import { MetadataClient, formatMetadataError } from '@object-ui/data-objectstack';
const client = new MetadataClient({ baseUrl: '/api/v1' });
try {
await client.save('object', 'account', {
name: 'account',
fields: { 'Bad Name': { type: 'text', label: 'Bad' } },
});
} catch (err) {
showBanner(formatMetadataError(err));
// • fields.Bad Name — Invalid key in record
// • fields.Bad Name — Field names must be lowercase snake_case (e.g., "first_name", …)
}
declare function showBanner(text: string): void;It writes one line per issue, naming the field, and falls back to the error's
message when there are no issues. Render the text with a class that keeps
newlines (whitespace-pre-line). formatMetadataIssue(issue) is the same
one-line format for a single issue, for code that lists several failures, such
as a publish response's failed[].
A select field can name a shared picklist (picklist: 'industry') instead of
carrying its own options. The server serves such a field with both keys:
picklist, and the options it resolved from the list. The server's authoring
check refuses the two together, with 422 INVALID_METADATA at
fields.FIELD.options, and that refuses the whole object. So code that reads an
object, edits it, and saves the whole document back must drop those resolved
options first. dropServedPicklistOptions does that:
import { MetadataClient, dropServedPicklistOptions } from '@object-ui/data-objectstack';
const client = new MetadataClient({ baseUrl: '/api/v1' });
const account = await client.get<Record<string, unknown>>('object', 'account');
const edited = { ...account, label: 'Customer' };
await client.save('object', 'account', dropServedPicklistOptions(edited));It removes options from every field that names a picklist, and touches
nothing else: a field without picklist keeps its inline options, and every
other key is kept. It never mutates its input, and it returns the same object
when there is nothing to remove.
Apply it only to a body built from a served read. MetadataClient.save does
not apply it for you: the client cannot tell a served copy from an author who
wrote picklist and options together, and that second case should stay the
server's refusal, which says which key to delete.
In addition to the main DataSource adapter, this package ships
createObjectStackUserStateAdapter — a small factory that lets Object UI
persist per-user UI state (favorites, recent items) into ObjectStack.
import { createObjectStackUserStateAdapter, type ObjectStackAdapter } from '@object-ui/data-objectstack';
import { useAttachUserStateAdapters } from '@object-ui/app-shell';
declare const dataSource: ObjectStackAdapter;
declare const user: { id: string };
const favoritesAdapter = createObjectStackUserStateAdapter({
dataSource, // the ObjectStack DataSource
userId: user.id,
key: 'ui.favorites', // or 'ui.recent', 'ui.grid.account.state', ...
// resource: 'sys_user_preference', // default — the unified per-user KV store
// onError: (op, err) => console.warn(`[user-state] ${op} failed`, err),
});
const attach = useAttachUserStateAdapters();
attach('favorites', favoritesAdapter);The adapter writes to the platform's unified per-user KV store —
sys_user_preference — shipped by every @objectstack/plugin-auth
environment. One row per (user_id, key) pair:
object: sys_user_preference
fields:
- { name: user_id, type: lookup(sys_user), indexed: true }
- { name: key, type: string, indexed: true }
- { name: value, type: json }
- { name: updated_at, type: datetime }
unique: [user_id, key]By convention, namespace UI-trace keys under ui.* (e.g. ui.favorites,
ui.recent, ui.grid.<object>.state, ui.sidebar.collapsed) so they
stay easy to tell apart from user-facing preferences (theme, locale).
If the backend doesn't yet expose sys_user_preference, every call
404s and the UI silently degrades to localStorage-only persistence. See
User-Scoped State Persistence
for the full design.
new ObjectStackAdapter(config: {
baseUrl: string;
token?: string;
fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
cache?: {
maxSize?: number;
ttl?: number;
};
autoReconnect?: boolean;
maxReconnectAttempts?: number;
reconnectDelay?: number;
})connect()- Establish connection to ObjectStack serverfind(resource, params?)- Query multiple recordsfindOne(resource, id, params?)- Get a single record by IDcreate(resource, data)- Create a new recordupdate(resource, id, data)- Update an existing recorddelete(resource, id)- Delete a recordbulk(resource, operation, data)- Batch operations (create/update/delete)batchTransaction(operations)- Cross-object atomic batch (master-detail); atomic when the backend advertisestransactionalBatch, else non-atomic client-side fallbackgetObjectSchema(objectName)- Get schema metadata (cached)getCacheStats()- Get cache statisticsinvalidateCache(key?)- Invalidate cache entriesclearCache()- Clear all cache entriesgetClient()- Access underlying ObjectStack clientgetConnectionState()- Get current connection stateisConnected()- Check if adapter is connectedonConnectionStateChange(listener)- Subscribe to connection state changes (returns unsubscribe function)onBatchProgress(listener)- Subscribe to batch operation progress (returns unsubscribe function)
- Enable Caching: Use default cache settings for optimal performance
- Handle Errors: Use typed error handling for better user experience
- Batch Operations: Use bulk methods for large datasets
- Monitor Cache: Check cache hit rates in production
- Invalidate Wisely: Clear cache after schema changes
- Connection Monitoring: Subscribe to connection state changes for better UX
- Auto-Reconnect: Use default auto-reconnect settings for resilient applications
- Batch Progress: Monitor progress for long-running bulk operations
import type { ObjectStackAdapter } from '@object-ui/data-objectstack';
declare const dataSource: ObjectStackAdapter;
// Error: MetadataNotFoundError
// Solution: Verify object name and ensure schema exists on server
const schema = await dataSource.getObjectSchema('correct_object_name');import { createObjectStackAdapter } from '@object-ui/data-objectstack';
// Error: ConnectionError
// Solution: Check baseUrl and network connectivity
const dataSource = createObjectStackAdapter({
baseUrl: 'https://correct-url.example.com',
token: 'valid-token'
});import { createObjectStackAdapter } from '@object-ui/data-objectstack';
const dataSource = createObjectStackAdapter({ baseUrl: 'https://api.example.com' });
// Clear cache if stale data is being returned
dataSource.clearCache();
// Or invalidate specific entries
dataSource.invalidateCache('users');MIT — see LICENSE.