Skip to content

Latest commit

 

History

History
708 lines (571 loc) · 28 KB

File metadata and controls

708 lines (571 loc) · 28 KB
title Client SDK
description The official TypeScript client for ObjectStack — auto-discovery, typed metadata, CRUD, batch operations, and service-aware feature detection.

Client SDK

The @objectstack/client is the official TypeScript client for ObjectStack. It provides a typed, protocol-aware interface that automatically adapts to your server's available services.

Features

  • Auto-Discovery: Connects to your ObjectStack server and discovers available services, routes, and capabilities
  • Service-Aware: Checks discovery.services to determine which features are available before calling them
  • Typed Metadata: Retrieve Object and View definitions with full type support
  • Metadata Caching: ETag-based conditional requests for efficient metadata caching
  • Unified Data Access: Simple CRUD operations for any object in your schema
  • Batch Operations: Efficient bulk create/update/upsert/delete with transaction support
  • Query Builder: Programmatic query construction with createQuery() and createFilter()
  • Standardized Errors: Machine-readable error codes with retry guidance
  • Protocol Compliant: Implements the core API namespaces defined in @objectstack/spec, plus additional namespaces (approvals, organizations, projects/environments)

Installation

pnpm add @objectstack/client

Quick Start

import { ObjectStackClient } from '@objectstack/client';

const client = new ObjectStackClient({
  baseUrl: 'http://localhost:3004',
});

async function main() {
  // 1. Connect — resolves with the discovery manifest
  const discovery = await client.connect();

  // 2. Check available services
  console.log('Services:', discovery.services);
  // → { metadata: { enabled: true, status: 'degraded' }, data: { enabled: true, status: 'available' }, auth: { enabled: false, ... } }

  // 3. Query data
  const tasks = await client.data.find('todo_task', {
    fields: ['subject', 'priority'],
    where: { priority: { $gte: 2 } },
    orderBy: ['-priority'],
    limit: 10
  });

  // 4. Create a record
  const newTask = await client.data.create('todo_task', {
    subject: 'New Task',
    priority: 1
  });

  // 5. Batch operations
  const result = await client.data.batch('todo_task', {
    operation: 'update',
    records: [
      { id: '1', data: { status: 'active' } },
      { id: '2', data: { status: 'active' } }
    ],
    options: { atomic: true, returnRecords: true }
  });
  console.log(`Updated ${result.succeeded} records`);
}

Auto-Discovery

When you call client.connect(), the client:

  1. Probes /api/v1/discovery first, then falls back to {origin}/.well-known/objectstack
  2. Parses the discovery response including routes, capabilities, and services
  3. Configures all API route paths dynamically
// connect() resolves with the discovery manifest — capture the return value
const discovery = await client.connect();

console.log(discovery.version);      // "1.0.0"
console.log(discovery.environment);  // "development"

// Check if a service is available before using it
if (discovery.services?.auth?.enabled) {
  // Auth plugin is installed — login is available
  await client.auth.login({ email: 'admin@example.com', password: 'secret' });
} else {
  console.log(discovery.services?.auth?.message);
  // → "Install an auth plugin to enable"
}
**Plugin-driven services**: Auth, workflow, automation, AI, and other services are only available when the corresponding plugin is installed. Always check `discovery.services` before calling plugin-provided API methods.

Protocol Coverage

The @objectstack/client SDK aims to implement the ObjectStack API protocol specification. The core namespaces are listed below; the client also exposes additional namespaces (approvals, organizations, oauth, projects/environments) — see index.ts for the full surface:

Namespace Status Methods Purpose
discovery 1 API version & capabilities detection
meta 18 Metadata read/write, published versions & drafts (ADR-0033), per-item publish/rollback/diff, FSM introspection (ADR-0020), diagnostics, references, audit trail, book trees (ADR-0046)
data 12 CRUD & query operations, record clone, streaming export (M10.9)
auth 5 Authentication & user management
packages 17 Package lifecycle: install/enable, drafts (ADR-0033), commits & rollback (ADR-0067), export/duplicate (ADR-0070)
analytics 4 Analytics queries + semantic-layer dataset query (ADR-0021)
automation 18 Flow CRUD, trigger/execute, runs, screen-flow resume, descriptor/status registries
actions 2 Server-registered action handlers (engine.registerAction)
approvals 12 Approval requests (ADR-0019): inbox, decisions, recall, revise/resubmit (ADR-0044), thread interactions, audit trail
reports 8 Saved reports: definitions, execution, recurring email schedules (501 without @objectstack/plugin-reports)
shares 8 Per-record sharing grants (list/grant/revoke) + tenant-wide sharing rules (shares.rules.*, M10.17)
search 1 Global cross-object search (M10.5)
email 1 Transactional send via IEmailService (M11.B1)
datasources 5 External-datasource federation admin: browse/draft/import remote tables (ADR-0015)
keys 1 API key minting (one-time secret)
shareLinks 3 Record share-link management
security 4 Access explanation (ADR-0090 D6) + suggested audience bindings (admin)
storage 2 File upload & download
i18n 3 Internationalization
notifications 3 List, mark-read, mark-all-read (inbox/receipt spine, ADR-0030)
ai 10 Chat (JSON + streaming), completion, model picker, conversation CRUD — the surface service-ai (Cloud/EE) mounts

The former permissions, views, workflow, and realtime namespaces (and the notifications device/preference helpers) were removed in #3612: no server surface ever mounted their routes, so every call was a guaranteed 404.

**Coverage is CI-enforced, not hand-asserted**: the #3563 route ledger ([`route-ledger.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/runtime/src/route-ledger.ts)) records the audited disposition of every server route, and conformance tests on both sides fail when a route lands without a reviewed disposition or the ledger names a client method that doesn't exist. Integration test specifications live in [`CLIENT_SERVER_INTEGRATION_TESTS.md`](https://github.com/objectstack-ai/objectstack/blob/main/packages/client/CLIENT_SERVER_INTEGRATION_TESTS.md).

API Namespaces

client.meta — Metadata

// Get object schema (getItem('object', name) returns the object definition)
const accountSchema = await client.meta.getItem('object', 'account');
console.log(accountSchema.fields);

// List all metadata types
const types = await client.meta.getTypes();
// → { types: ['object', 'view', 'plugin', ...] }

// Get with ETag caching
const cached = await client.meta.getCached('account', {
  ifNoneMatch: '"686897696a7c876b7e"'
});
if (cached.notModified) {
  console.log('Using cached metadata');
}

// Get auto-generated view
const listView = await client.meta.getView('account', 'list');

// Compound names (sub-resources) pass through unencoded
const view = await client.meta.getItem('object', 'views/all_leads');

// Per-item draft lifecycle (ADR-0033)
await client.meta.publishItem('object', 'account', { message: 'go live' });
await client.meta.rollbackItem('object', 'account', 3);
const diff = await client.meta.diffItem('object', 'account', { from: 2, to: 5 });

// Introspection & governance
const bad = await client.meta.getDiagnostics({ severity: 'error' });
const refs = await client.meta.getReferences('object', 'account');
const trail = await client.meta.getAudit('object', 'account', { limit: 20 });
const tree = await client.meta.getBookTree('handbook');

client.data — CRUD & Batch

// Find with query options
const accounts = await client.data.find('account', {
  fields: ['name', 'industry', 'revenue'],
  where: { industry: 'Technology' },
  orderBy: ['-revenue'],
  limit: 20,
  offset: 0,
});

// Get by ID
const account = await client.data.get('account', '123');

// Create
const created = await client.data.create('account', {
  name: 'Acme Corp',
  industry: 'Technology',
});

// Update (partial)
const updated = await client.data.update('account', '123', {
  industry: 'Healthcare',
});

// Delete
await client.data.delete('account', '123');

// Batch operation (recommended for bulk)
const batchResult = await client.data.batch('account', {
  operation: 'upsert',
  records: [
    { id: '1', data: { name: 'Acme', status: 'active' } },
    { id: '2', data: { name: 'Beta', status: 'active' } },
  ],
  options: {
    atomic: true,
    returnRecords: true,
    continueOnError: false,
    validateOnly: false,
  },
});

// Convenience bulk methods
await client.data.createMany('account', [{ name: 'A' }, { name: 'B' }]);
await client.data.updateMany('account', [
  { id: '1', data: { status: 'closed' } },
  { id: '2', data: { status: 'closed' } },
]);
await client.data.deleteMany('account', ['1', '2', '3']);

// Atomic cross-object batch (master-detail save) — runs every operation in
// ONE server-side transaction (POST /api/v1/batch): commit all or roll back
// all. `{ $ref: <op index> }` lets a child reference a parent created earlier
// in the same request. Always atomic — unlike per-object `data.batch()`,
// there is no partial-success mode.
const { results } = await client.data.batchTransaction([
  { object: 'project', action: 'create', data: { name: 'Apollo' } },
  { object: 'task', action: 'create', data: { title: 'Kickoff', project: { $ref: 0 } } },
]);

See Batch Operations for the underlying endpoint contract.

client.analytics — BI Queries

// Semantic analytics query (cube-style)
const result = await client.analytics.query({
  cube: 'account',
  measures: ['revenue.sum', 'count'],
  dimensions: ['industry'],
  where: { status: 'active' },
  limit: 100,
});

// Get cube metadata — all cubes, or one with meta('account')
const meta = await client.analytics.meta('account');

// Dry-run a query to its generated SQL (POST /analytics/sql)
const explained = await client.analytics.explain({
  cube: 'account',
  measures: ['revenue.sum'],
});

client.packages — Package Management

const packages = await client.packages.list();
await client.packages.install({ id: 'com.objectstack.plugin-auth', version: '1.0.0' });
await client.packages.enable('com.objectstack.plugin-auth');
await client.packages.disable('com.objectstack.plugin-auth');
await client.packages.uninstall('com.objectstack.plugin-auth');

Additional Namespaces

The client also provides full implementations for:

// Auth — User authentication and session management
await client.auth.login({ email: 'user@example.com', password: 'pass' });
await client.auth.register({ email: 'new@example.com', password: 'pass', name: 'New User' });
await client.auth.me();
await client.auth.logout();
await client.auth.refreshToken('refresh-token-string');

// Approvals — request-based decision API (ADR-0019)
// Approval is a flow node, not a workflow step: decisions are keyed by request id.
await client.approvals.listRequests({ status: 'pending' }); // "my approvals" inbox
await client.approvals.getRequest(requestId);
await client.approvals.approve(requestId, { comment: 'Looks good' });
await client.approvals.reject(requestId, { comment: 'Incomplete' });
await client.approvals.listActions(requestId); // audit trail

// Notifications — inbox/receipt spine (ADR-0030)
await client.notifications.list({ read: false });
await client.notifications.markRead(['notif-1', 'notif-2']);
await client.notifications.markAllRead();

// ADR-0030 note: the framework pipeline now materializes in-app messages
// through sys_inbox_message and tracks read-state in sys_notification_receipt.
// These helpers will be repointed during the objectui bell cut-over.

// AI — served by `service-ai` (Cloud/EE); 404s "AI service is not configured"
// when the plugin is absent, so check `discovery.services` first.
const answer = await client.ai.chat({
  messages: [{ role: 'user', content: 'How many open orders this quarter?' }],
  conversationId,                        // omit to have one created and echoed back
});
answer.content;                          // string
answer.usage?.totalTokens;

// Streaming — the Vercel UI Message Stream Protocol, frame by frame.
for await (const frame of await client.ai.chatStream({ messages })) {
  if (frame.type === 'text-delta') process.stdout.write(frame.delta as string);
}

await client.ai.complete({ prompt: 'Summarise this account in one line:' });
await client.ai.models();                // plan-filtered picker list (ADR-0028)

// Conversations — all six routes, scoped to the authenticated user server-side.
const conv = await client.ai.conversations.create({ title: 'Q3 pipeline' });
await client.ai.conversations.list({ limit: 20 });
await client.ai.conversations.get(conv.id);
await client.ai.conversations.addMessage(conv.id, { role: 'user', content: 'hi' });
await client.ai.conversations.update(conv.id, { title: 'Renamed' });
await client.ai.conversations.delete(conv.id);

// Named agents — `ai.chat` talks to the environment default; these talk to one
// you name. The catalog is access-filtered server-side (ADR-0049), so an empty
// list is a legitimate answer for a seat-less user, not an error to retry.
const agents = await client.ai.agents.list();
await client.ai.agents.chat('build', { messages, context: { appId: 'crm' } });
for await (const frame of await client.ai.agents.chatStream('build', { messages })) {
  if (frame.type === 'text-delta') process.stdout.write(frame.delta as string);
}

// Pending actions — the human-in-the-loop queue. A tool call needing a human
// decision parks here instead of executing.
await client.ai.pendingActions.list({ status: 'pending', limit: 20 });
await client.ai.pendingActions.get('pa_1');
const outcome = await client.ai.pendingActions.approve('pa_1');
// CHECK `outcome.status`: approve runs the tool, and a tool that fails comes
// back { status: 'failed', error } on HTTP 200 — the approval succeeded, the
// execution did not. Code that reads only `res.ok` calls that a success.
if (outcome.status === 'failed') console.error(outcome.error);
await client.ai.pendingActions.reject('pa_1', 'wrong account');
// Listing takes `ai:read`, deciding takes `ai:approve` — a caller that can see
// the queue may still be refused on approve. Handle the 403.

// In a React chat UI prefer `useChat()` (`@ai-sdk/react`) over `ai.chatStream`:
// it speaks the same protocol and owns message state. These methods are for
// everything that is not a component — server code, jobs, CLIs, tests.
//
// #3718 history: `client.ai` used to hold `nlq`, `suggest` and `insights`,
// building /api/v1/ai/{nlq,suggest,insights}. No server in any repo ever
// mounted those paths, so every call 404ed for the whole life of the
// namespace. They were typed and shipped, which is exactly why they looked
// usable. v17 removed them; the methods above are the surface that exists.

// i18n — Internationalization
await client.i18n.getLocales();
await client.i18n.getTranslations('zh-CN');
await client.i18n.getFieldLabels('account', 'zh-CN');

// Automation — Trigger workflows and automations
await client.automation.trigger('send_welcome_email', { userId });

// Screen flows pause for user input instead of completing. `execute()` returns
// `{ status: 'paused', runId, screen }`; render the screen, then resume the run
// with the collected values. A wizard pauses again for each further step.
const run = await client.automation.execute('convert_lead', { params: { recordId } });
if (run.status === 'paused') {
  await client.automation.resume('convert_lead', run.runId, {
    inputs: { account_name: 'Radium Labs' },
  });
}
// Re-fetch the pending screen when the client did not launch the run itself
// (a page reload, another tab, an inbox):
await client.automation.getScreen('convert_lead', runId);

// Actions — Invoke server-registered action handlers
// (engine.registerAction). The result is `{ success, data | error }` — a
// failure comes back as `success: false` + `error` instead of a thrown
// exception, so it can be surfaced as a toast. This surface is the one place
// a non-2xx does NOT throw: a handler that rejects answers 200 with the inner
// envelope, a request that never dispatched answers 404 / 403 / 400 / 503,
// and the SDK folds both into the same result.
const res = await client.actions.invoke('crm_lead', 'convert', {
  recordId,
  params: { create_opportunity: true },
});
if (!res.success) console.warn(res.error);
// Global (object-less) actions dispatch to the server's 'global' handler key:
await client.actions.invokeGlobal('nightly_cleanup', { params: { dryRun: true } });

// API Keys — the raw secret is returned exactly ONCE; store it immediately.
const apiKey = await client.keys.create({ name: 'CI key', expiresAt: '2027-01-01' });
console.log(apiKey.key); // never re-displayable

// Share Links — manage record share links (public token URLs stay browser-only)
const link = await client.shareLinks.create('crm_account', recordId, {
  permission: 'view',
  expiresAt: '2026-12-31',
});
await client.shareLinks.list({ object: 'crm_account' });
await client.shareLinks.revoke(link.token);

// Security (admin) — resolve package audience-binding suggestions (ADR-0090)
const { suggestions } = await client.security.suggestedBindings.list({ status: 'pending' });
await client.security.suggestedBindings.confirm(suggestions[0].id);

// Storage — File upload and management
await client.storage.upload(fileData, 'user');
await client.storage.getDownloadUrl('file-123');
**Service availability**: Optional services (automation, ai, etc.) are only available when the corresponding plugin is installed on the server. Always check the `services` map on the discovery result returned by `client.connect()` (cache it yourself — the client has no `discovery` getter) to verify service availability before calling these methods.

Query Builder

Build complex queries programmatically:

import { createQuery, createFilter } from '@objectstack/client';

const query = createQuery('account')
  .select('name', 'industry', 'revenue')
  .where((f) => {
    f.equals('industry', 'Technology');
    f.greaterThan('revenue', 10000);
  })
  .orderBy('revenue', 'desc')
  .limit(50)
  .build();

const results = await client.data.query('account', query);

Filter Builder Methods

The FilterBuilder provides a rich set of filter methods:

import { createFilter } from '@objectstack/client';

const filter = createFilter()
  .equals('status', 'active')        // field = value
  .notEquals('type', 'archived')      // field != value
  .greaterThan('revenue', 10000)      // field > value
  .lessThanOrEqual('age', 100)        // field <= value
  .in('category', ['A', 'B', 'C'])   // field IN (...)
  .like('name', '%Corp%')             // field LIKE pattern
  .contains('name', 'Corp')           // LIKE %Corp%
  .startsWith('name', 'Acme')         // LIKE Acme%
  .isNull('deleted_at')               // field IS NULL
  .between('created_at', '2024-01', '2024-12')
  .build();

Query Options

The find method accepts an options object with canonical (recommended) field names:

Property Type Description Example
where Object or FilterCondition Filter conditions (WHERE clause) { status: 'active' }
fields string[] Fields to retrieve (SELECT) ['name', 'email']
orderBy string or string[] or SortNode[] Sort order (ORDER BY) ['-created_at']
limit number Max records to return (LIMIT) 20
offset number Records to skip (OFFSET) 0
expand Record<string, any> or string[] Relation loading (JOIN) { owner: {} }
**Canonical names recommended:** `find()` accepts the canonical names above; the legacy aliases (`select`, `filter`/`filters`, `sort`, `top`, `skip`) still work for backward compatibility, but new code should use the canonical names. (`@objectstack/client-react`'s `useQuery`/`useInfiniteQuery` hooks are stricter — the legacy aliases were removed there in v11; see `docs/upgrading-to-11.md`.)

Batch Options

Property Type Default Description
atomic boolean true Rollback entire batch on any failure
returnRecords boolean false Include full records in response
continueOnError boolean false Continue after errors (when atomic=false)
validateOnly boolean false Dry-run mode — validate without persisting

Error Handling

All errors follow a standardized format:

try {
  await client.data.create('todo_task', { subject: '' });
} catch (error) {
  console.error(error.code);       // 'VALIDATION_ERROR'
  console.error(error.category);   // 'validation'
  console.error(error.httpStatus); // 400
  console.error(error.retryable);  // false
  console.error(error.details);    // { ... }
}

error.code is always the semantic code as a string — the numeric HTTP status lives on error.httpStatus and nowhere else. This holds regardless of which server surface answered: the REST server replies with a flat { error, code, fields } body and the runtime dispatcher replies with a wrapped { success, error: { code, message, httpStatus, details } } body, and the client normalizes both before throwing. (Dispatcher bodies from servers older than #3842 put the status in error.code and the semantic code in error.details.code; the client still reads those, so an SDK newer than the server it talks to behaves the same.)

Per-field validation errors

A validation failure additionally carries error.fields — one entry per offending field, ready to attach to the inputs that produced them:

try {
  await client.data.create('contact', { email: 'not-an-email' });
} catch (error) {
  if (error.code === 'VALIDATION_FAILED') {
    for (const f of error.fields ?? []) {
      showFieldError(f.field, f.message);   // 'email', 'email must be a valid email address'
    }
  }
}

error.fields is left unset (not []) when the server reported no per-field detail, so if (error.fields) is a safe test for "this failure is field-anchored".

Error Codes

Code HTTP Category Retryable Description
VALIDATION_ERROR 400 validation No Input validation failed
INVALID_QUERY 400 validation No Malformed query expression
UNAUTHENTICATED 401 authentication No Authentication required
PERMISSION_DENIED 403 authorization No Insufficient permissions
RESOURCE_NOT_FOUND 404 not_found No Resource does not exist
RATE_LIMIT_EXCEEDED 429 rate_limit Yes Too many requests
INTERNAL_ERROR 500 server Yes Unexpected server error
SERVICE_UNAVAILABLE 503 server Yes Service temporarily unavailable
NOT_IMPLEMENTED 501 server No Service not installed (plugin missing)

Configuration

interface ClientConfig {
  /** ObjectStack server URL */
  baseUrl: string;
  /** Bearer token for authentication (if auth plugin installed) */
  token?: string;
  /** Custom fetch implementation (e.g. for SSR or testing) */
  fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
  /** Logger instance for debugging */
  logger?: Logger;
  /** Enable debug logging */
  debug?: boolean;
  /** Active project/environment id — injects an `X-Environment-Id` header for multi-tenant routing */
  environmentId?: string;
  /** Active UI locale (BCP-47, e.g. `'zh-CN'`) — injects an `Accept-Language` header; sync via `setLocale()` */
  locale?: string;
}

React Hooks

For React applications, use @objectstack/client-react:

pnpm add @objectstack/client-react
import { ObjectStackProvider, useClient, useQuery } from '@objectstack/client-react';
import { ObjectStackClient } from '@objectstack/client';

// 1. Wrap your app with the provider
const client = new ObjectStackClient({ baseUrl: 'http://localhost:3004' });

function App() {
  return (
    <ObjectStackProvider client={client}>
      <AccountList />
    </ObjectStackProvider>
  );
}

// 2. Use hooks in child components
function AccountList() {
  const { data, isLoading, error, refetch } = useQuery('account', {
    where: { status: 'active' },
    orderBy: ['-created_at'],
    limit: 20,
  });

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  return data?.records.map(a => <div key={a.id}>{a.name}</div>);
}

Available Hooks

Hook Purpose
useClient() Access the ObjectStackClient instance from context
useQuery(object, options) Query data with auto caching and refetching
useMutation(object) Create/update/delete mutations
usePagination(object, options) Paginated data queries
useInfiniteQuery(object, options) Infinite scroll queries
useObject(name) Get object metadata
useView(object, type) Get view definition
useFields(object) Get field definitions
useMetadata(type, name) Get arbitrary metadata

Testing

The client SDK includes comprehensive unit and integration tests to ensure reliability and protocol compliance.

Unit Tests

cd packages/client
pnpm test

Unit tests use mocks to verify client behavior without requiring a server.

Integration Tests

Note: Integration tests require a running ObjectStack server. The server is provided by a separate repository and must be set up independently.

# Prerequisite: Start an ObjectStack server with test data
# For example, using the reference server repository
# Follow the server repository's documentation for local setup

# From this repository, run the integration test script
cd packages/client
pnpm test:integration

Integration tests verify end-to-end communication with a live ObjectStack server across the client's API namespaces.

**Test coverage**: Integration test specifications cover discovery/connection, authentication, metadata operations, CRUD operations (basic, batch, advanced queries), permissions, workflow, realtime, notifications, AI services, i18n, analytics, packages, views, storage, and automation.

Protocol Compliance Documentation

For detailed information about the client's protocol implementation:


Next Steps