Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/client-actions-surface.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"@objectstack/client": minor
"@objectstack/runtime": patch
---

feat(client): `actions` surface — the SDK path to server-registered actions (#3563 PR-2)

`client.actions.invoke(object, action, { recordId, params })` and
`client.actions.invokeGlobal(action, opts)` dispatch handlers registered via
`engine.registerAction` (`POST /api/v1/actions/...`). This closes the largest
gap in the #3563 route audit: the whole `/actions` domain — the documented way
to expose custom server-side operations — was unreachable from the SDK, and
every console hand-rolled `fetch` for it. The record id travels in the body,
which both server URL shapes honor; the handler's own business failure comes
back as `{ success: false, error }` rather than a thrown exception.

The route ledger flips all three `/actions` rows to `sdk` and the gap ratchet
drops 27 → 24. Also takes the documentation-drift findings from the audit:
the client README no longer documents six methods that do not exist,
`CLIENT_SPEC_COMPLIANCE.md` is retired to a tombstone pointing at the
CI-enforced ledger (its "FULLY COMPLIANT" verdict was measured against a
route table nothing consumes), and the docs-site SDK page documents the new
surface.
24 changes: 22 additions & 2 deletions content/docs/api/client-sdk.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -117,15 +117,22 @@ The `@objectstack/client` SDK aims to implement the ObjectStack API protocol spe
| **views** | ✅ | 5 | UI view definitions |
| **workflow** | ✅ | 3 | Workflow state transitions |
| **analytics** | ✅ | 3 | Analytics queries |
| **automation** | ✅ | 1 | Automation triggers |
| **automation** | ✅ | 15 | Flow CRUD, trigger/execute, runs, screen-flow resume |
| **actions** | ✅ | 2 | Server-registered action handlers (`engine.registerAction`) |
| **storage** | ✅ | 2 | File upload & download |
| **i18n** | ✅ | 3 | Internationalization |
| **notifications** | 🟡 | 7 | Legacy notification helpers; receipt/inbox cut-over pending |
| **realtime** | ✅ | 6 | Connection/subscription/presence calls (server transport is plugin-provided) |
| **ai** | ✅ | 3 | AI services (NLQ, suggest, insights) |

<Callout type="info">
**Protocol compliance & verification**: See [`CLIENT_SPEC_COMPLIANCE.md`](https://github.com/objectstack-ai/objectstack/blob/main/packages/client/CLIENT_SPEC_COMPLIANCE.md) for detailed method-by-method verification and [`CLIENT_SERVER_INTEGRATION_TESTS.md`](https://github.com/objectstack-ai/objectstack/blob/main/packages/client/CLIENT_SERVER_INTEGRATION_TESTS.md) for comprehensive integration test specifications.
**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).
</Callout>

---
Expand Down Expand Up @@ -332,6 +339,19 @@ if (run.status === 'paused') {
// (a page reload, another tab, an inbox):
await client.automation.getScreen('convert_lead', runId);

// Actions — Invoke server-registered action handlers
// (engine.registerAction). The result envelope is the HANDLER's own
// `{ success, data | error }` — a business failure comes back as
// `success: false` + `error` instead of a thrown exception, so it can be
// surfaced as a toast.
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 wildcard handler:
await client.actions.invokeGlobal('nightly_cleanup', { params: { dryRun: true } });

// Storage — File upload and management
await client.storage.upload(fileData, 'user');
await client.storage.getDownloadUrl('file-123');
Expand Down
387 changes: 26 additions & 361 deletions packages/client/CLIENT_SPEC_COMPLIANCE.md

Large diffs are not rendered by default.

66 changes: 28 additions & 38 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ The official TypeScript client for ObjectStack.
- **Batch Operations**: Efficient bulk create/update/delete with transaction support.
- **View Storage**: Save, load, and share custom UI view configurations.
- **Standardized Errors**: Machine-readable error codes with retry guidance.
- **Full Protocol Coverage**: Implements all 13 API namespaces defined in `@objectstack/spec`
- **95+ Methods**: Complete implementation of discovery, metadata, data, auth, workflow, realtime, AI, and more.
- **Broad Protocol Coverage**: 20 top-level surfaces (~170 methods) over the ObjectStack HTTP API.
- **Audited Surface**: coverage against the server's routes is tracked by the #3563 route ledger (`@objectstack/runtime` `route-ledger.ts`), enforced in CI.

## 🤖 AI Development Context

Expand Down Expand Up @@ -47,7 +47,7 @@ async function main() {
await client.connect();

// 3. Metadata Access
const todoSchema = await client.meta.getObject('todo_task');
const todoSchema = await client.meta.getItem('object', 'todo_task');
console.log('Fields:', todoSchema.fields);

// Save Metadata (New Feature)
Expand Down Expand Up @@ -93,10 +93,9 @@ async function main() {
}

// 8. View Storage (New!)
const view = await client.views.create({
const view = await client.views.create('todo_task', {
name: 'active_tasks',
label: 'Active Tasks',
object: 'todo_task',
type: 'list',
visibility: 'public',
query: {
Expand All @@ -121,9 +120,10 @@ async function main() {
Initializes the client by fetching the system discovery manifest from `/api/v1`.

### `client.meta`
- `getObject(name: string)`: Get object schema.
- `getCached(name: string, options?)`: Get object schema with ETag-based caching.
- `getView(name: string)`: Get view configuration.
- `getTypes()` / `getItems(type)` / `getItem(type, name)`: Read metadata (e.g. `getItem('object', 'todo_task')`).
- `saveItem(type, name, data)` / `deleteItem(type, name)`: Write metadata.
- `getCached(name, options?)`: Get object schema with ETag-based caching.
- `getView(object, type?)`: Get rendered view configuration.

### `client.data`
- `find<T>(object, options)`: Advanced query with filtering, sorting, selection.
Expand All @@ -137,14 +137,12 @@ Initializes the client by fetching the system discovery manifest from `/api/v1`.
- `delete(object, id)`: Delete record.
- `deleteMany(object, ids[], options?)`: Batch delete records (convenience method).

### `client.views` (New!)
- `create(request)`: Create a new saved view.
- `get(id)`: Get a saved view by ID.
- `list(request?)`: List saved views with optional filters.
- `update(request)`: Update an existing view.
- `delete(id)`: Delete a saved view.
- `share(id, userIds[])`: Share a view with users/teams.
- `setDefault(id, object)`: Set a view as default for an object.
### `client.views`
- `create(object, data)`: Create a new saved view.
- `get(object, viewId)`: Get a saved view by ID.
- `list(object, type?)`: List saved views.
- `update(object, viewId, data)`: Update an existing view.
- `delete(object, viewId)`: Delete a saved view.

### Query Options
The `find` method accepts an options object with canonical field names:
Expand Down Expand Up @@ -226,25 +224,16 @@ const data = await retryableRequest(() =>

## Protocol Compliance

The `@objectstack/client` SDK implements all 13 API namespaces defined in the `@objectstack/spec` protocol specification:

| Namespace | Purpose | Status |
|-----------|---------|:------:|
| `discovery` | API version & capabilities detection | ✅ |
| `meta` | Metadata operations (objects, plugins, etc.) | ✅ |
| `data` | CRUD & query operations | ✅ |
| `auth` | Authentication & user management | ✅ |
| `packages` | Plugin/package lifecycle management | ✅ |
| `views` | UI view definitions | ✅ |
| `workflow` | Workflow state transitions | ✅ |
| `analytics` | Analytics queries | ✅ |
| `automation` | Automation triggers | ✅ |
| `i18n` | Internationalization | ✅ |
| `notifications` | Push notifications | ✅ |
| `realtime` | WebSocket subscriptions | ✅ |
| `ai` | AI services (NLQ, chat, insights) | ✅ |

For detailed compliance verification, see [CLIENT_SPEC_COMPLIANCE.md](./CLIENT_SPEC_COMPLIANCE.md).
The SDK ships 20 top-level surfaces: `meta`, `data`, `auth`, `oauth`,
`organizations`, `projects`, `packages`, `views`, `permissions`, `workflow`,
`approvals`, `analytics`, `automation`, `actions`, `storage`, `i18n`,
`notifications`, `realtime`, `ai`, and `events`.

Coverage against the server's actual route surface is no longer asserted by a
hand-written table — it is audited and CI-enforced by the #3563 route ledger
(`packages/runtime/src/route-ledger.ts` + its conformance tests, one half in
runtime, one half in this package). See
`docs/audits/2026-07-dispatcher-client-route-coverage.md` for the audit.

## Available Namespaces

Expand Down Expand Up @@ -291,8 +280,10 @@ await client.permissions.getEffectivePermissions();
await client.workflow.getConfig('approval');
await client.workflow.getState('approval', recordId);
await client.workflow.transition({ object: 'approval', recordId, transition: 'submit' });
await client.workflow.approve({ object: 'approval', recordId });
await client.workflow.reject({ object: 'approval', recordId, reason: 'Incomplete' });

// Approvals (approve/reject live here, not on workflow)
await client.approvals.approve(requestId, 'LGTM');
await client.approvals.reject(requestId, 'Incomplete');

// Realtime
await client.realtime.connect({ protocol: 'websocket' });
Expand All @@ -306,7 +297,6 @@ await client.notifications.markRead(['notif-1', 'notif-2']);

// AI Services
await client.ai.nlq({ query: 'Show me all active contacts' });
await client.ai.chat({ message: 'Summarize this project', context: projectId });
await client.ai.suggest({ object: 'contact', field: 'industry' });
await client.ai.insights({ object: 'sales', recordId: dealId });

Expand Down
86 changes: 86 additions & 0 deletions packages/client/src/actions-surface.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `client.actions` — the SDK path to server-registered action handlers
* (`POST /api/v1/actions/...`), closing the largest #3563 gap: before this
* surface, the whole `/actions` domain was unreachable from the SDK.
*/

import { describe, it, expect, vi } from 'vitest';
import { ObjectStackClient } from './index';

function createMockClient(body: any, status = 200) {
const fetchMock = vi.fn().mockResolvedValue({
ok: status >= 200 && status < 300,
status,
statusText: status === 200 ? 'OK' : 'Error',
json: async () => body,
headers: new Headers()
});
const client = new ObjectStackClient({
baseUrl: 'http://localhost:3000',
fetch: fetchMock
});
return { client, fetchMock };
}

describe('client.actions', () => {
it('invoke POSTs to /api/v1/actions/:object/:action with recordId + params in the body', async () => {
const { client, fetchMock } = createMockClient({
success: true,
data: { success: true, data: { converted: true } },
});

const result = await client.actions.invoke('crm_lead', 'convert', {
recordId: 'lead_1',
params: { create_opportunity: true },
});

const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toBe('http://localhost:3000/api/v1/actions/crm_lead/convert');
expect(init.method).toBe('POST');
expect(JSON.parse(init.body)).toEqual({
recordId: 'lead_1',
params: { create_opportunity: true },
});
// unwrapResponse strips the transport envelope; the INNER envelope is
// the action handler's own result.
expect(result).toEqual({ success: true, data: { converted: true } });
});

it('invoke URL-encodes object and action names', async () => {
const { client, fetchMock } = createMockClient({ success: true, data: { success: true } });
await client.actions.invoke('my object', 'do/thing');
expect(String(fetchMock.mock.calls[0][0])).toBe(
'http://localhost:3000/api/v1/actions/my%20object/do%2Fthing',
);
});

it('invoke defaults params to {} and omits recordId when not given', async () => {
const { client, fetchMock } = createMockClient({ success: true, data: { success: true } });
await client.actions.invoke('crm_lead', 'recalculate');
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ params: {} });
});

it('invokeGlobal targets the wildcard shape /actions/global/:action', async () => {
const { client, fetchMock } = createMockClient({ success: true, data: { success: true } });
await client.actions.invokeGlobal('nightly_cleanup', { params: { dryRun: true } });
expect(String(fetchMock.mock.calls[0][0])).toBe(
'http://localhost:3000/api/v1/actions/global/nightly_cleanup',
);
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ params: { dryRun: true } });
});

it('surfaces the handler business failure without throwing (inner success:false)', async () => {
// The dispatcher reports handler throws as HTTP 200 with an inner
// `{ success:false, error }` — a toastable business failure, not a
// transport error (http-dispatcher.handleActions catch branch).
const { client } = createMockClient({
success: true,
data: { success: false, error: 'Lead is already converted' },
});
const result = await client.actions.invoke('crm_lead', 'convert', { recordId: 'lead_1' });
expect(result.success).toBe(false);
expect(result.error).toBe('Lead is already converted');
});
});
55 changes: 55 additions & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2355,6 +2355,61 @@ export class ObjectStackClient {
},
};

/**
* Server-registered Actions (#3563 gap closure)
*
* Dispatches named action handlers registered server-side via
* `engine.registerAction(objectName, actionName, handler)`. Until this
* surface existed, `POST /api/v1/actions/...` was entirely unreachable from
* the SDK — every console hand-rolled `fetch` for it (the largest functional
* hole found by the #3563 route audit).
*
* The path is fixed (`/api/v1/actions`), not discovery-routed: `actions` is
* not part of `ApiRoutesSchema`, so `getRoute()` cannot resolve it — same
* precedent as the `projects` surface's `/api/v1/cloud`.
*
* The dispatcher accepts the record id either in the URL or in the body;
* this client always sends it in the body (`{ recordId, params }`), which
* both server shapes honor. The HTTP envelope is
* `{ success, data: { success, data | error } }` — the OUTER envelope is
* transport success; the INNER `success:false` + `error` is the action
* handler's own (business) failure, deliberately not thrown so callers can
* surface it as a toast rather than a crash.
*/
actions = {
/**
* Invoke a server-registered action on an object.
* Falls back to the server's wildcard ('*') handler when no
* object-specific handler is registered.
*/
invoke: async <T = any>(
objectName: string,
actionName: string,
opts?: { recordId?: string; params?: Record<string, unknown> },
): Promise<{ success: boolean; data?: T; error?: string }> => {
const res = await this.fetch(
`${this.baseUrl}/api/v1/actions/${encodeURIComponent(objectName)}/${encodeURIComponent(actionName)}`,
{
method: 'POST',
body: JSON.stringify({ recordId: opts?.recordId, params: opts?.params ?? {} }),
},
);
return this.unwrapResponse(res) as Promise<{ success: boolean; data?: T; error?: string }>;
},

/**
* Invoke a global (object-less) action — the server's
* `POST /actions/global/:action` shape, dispatched to the wildcard
* handler registry.
*/
invokeGlobal: async <T = any>(
actionName: string,
opts?: { recordId?: string; params?: Record<string, unknown> },
): Promise<{ success: boolean; data?: T; error?: string }> => {
return this.actions.invoke<T>('global', actionName, opts);
},
};

/**
* Event Subscription API
* Provides real-time event subscriptions for metadata and data changes
Expand Down
8 changes: 4 additions & 4 deletions packages/runtime/src/route-ledger.conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,10 @@ describe('route ledger hygiene', () => {
});

it('gap count only shrinks — update the ledger (and this number) when closing gaps', () => {
// Ratchet, not aspiration: 27 audited gaps at #3563 PR-1. Closing one =
// reclassify the entry to `sdk` AND lower this bound. Raising it demands
// an explicit, reviewed decision to ship a new un-expressed route.
// Ratchet, not aspiration: 27 audited gaps at #3563 PR-1; 24 after PR-2
// (actions surface). Closing a gap = reclassify to `sdk` AND lower this
// bound. Raising it demands an explicit, reviewed decision.
const gaps = ROUTE_LEDGER.filter((e) => e.disposition === 'gap').length;
expect(gaps).toBeLessThanOrEqual(27);
expect(gaps).toBeLessThanOrEqual(24);
});
});
8 changes: 4 additions & 4 deletions packages/runtime/src/route-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,10 +189,10 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [
{ route: '* /mcp/**', domain: '/mcp', disposition: 'server-only', note: 'MCP Streamable HTTP transport — consumed by MCP clients, not this SDK' },

// ── actions ───────────────────────────────────────────────────────────────
{ route: 'POST /actions/:object/:action', domain: '/actions', disposition: 'gap',
note: 'server-registered actions (engine.registerAction) are entirely unreachable from the SDK — the largest functional hole' },
{ route: 'POST /actions/:object/:action/:recordId', domain: '/actions', disposition: 'gap', note: 'record-scoped variant — same hole' },
{ route: 'POST /actions/global/:action', domain: '/actions', disposition: 'gap', note: 'wildcard variant — same hole' },
{ route: 'POST /actions/:object/:action', domain: '/actions', disposition: 'sdk', client: 'actions.invoke' },
{ route: 'POST /actions/:object/:action/:recordId', domain: '/actions', disposition: 'sdk', client: 'actions.invoke',
note: 'client sends recordId in the body — both server shapes honor it' },
{ route: 'POST /actions/global/:action', domain: '/actions', disposition: 'sdk', client: 'actions.invokeGlobal' },

// ── misc legacy ───────────────────────────────────────────────────────────
{ route: 'GET /openapi.json', domain: '/openapi.json', disposition: 'server-only', note: 'docs tooling; falls through when metadata service lacks a generator' },
Expand Down