Skip to content

Commit f7e6712

Browse files
committed
feat(client): actions surface — the SDK path to server-registered actions (#3563 PR-2)
Closes the largest gap in the #3563 route audit: the whole /actions domain — the documented way to expose custom server-side operations (engine.registerAction) — had no SDK expression at all, so every console hand-rolled fetch for it. - client.actions.invoke(object, action, { recordId, params }) — POST /api/v1/actions/:object/:action. recordId travels in the body, which both server URL shapes honor. The path is fixed (not discovery-routed): actions is not in ApiRoutesSchema, same precedent as projects' /api/v1/cloud. - client.actions.invokeGlobal(action, opts) — the wildcard /actions/global/:action shape. - The handler's own business failure surfaces as { success:false, error } (the dispatcher's inner envelope), deliberately not thrown, so callers can toast it. Unit tests cover URL shape, encoding, body defaults, the global variant, and the inner-failure path. Ledger: all three /actions rows flip to sdk; the gap ratchet drops 27 → 24 — first real exercise of the PR-1 guard. Documentation stops lying (audit findings): - client README: the six phantom methods are gone (meta.getObject, views.share, views.setDefault, workflow.approve/reject, ai.chat — replaced with the real calls where one exists), and the hand-written "all 13 namespaces / FULLY COMPLIANT" claims now defer to the CI-enforced ledger. - CLIENT_SPEC_COMPLIANCE.md: retired to a tombstone. Its verdict was measured against DEFAULT_DISPATCHER_ROUTES — a table nothing in runtime consumes, missing five of the domains it should have been measuring. - content/docs/api/client-sdk.mdx: documents the new actions surface; the automation row now reflects the real method count. Pre-existing, untouched: the bare `tsc -p` TS2741 (routeMap missing graphql) reproduces identically on main — its root is the stale graphql entry in ApiRoutesSchema, tracked as audit follow-up #7 (spec-major territory). Verified: client 122 passed (5 new), runtime 653 passed, both ledger guard halves green, eslint clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LX9ut3MK3KykE11S9bJmv5
1 parent 48d5a1c commit f7e6712

8 files changed

Lines changed: 248 additions & 409 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@objectstack/client": minor
3+
"@objectstack/runtime": patch
4+
---
5+
6+
feat(client): `actions` surface — the SDK path to server-registered actions (#3563 PR-2)
7+
8+
`client.actions.invoke(object, action, { recordId, params })` and
9+
`client.actions.invokeGlobal(action, opts)` dispatch handlers registered via
10+
`engine.registerAction` (`POST /api/v1/actions/...`). This closes the largest
11+
gap in the #3563 route audit: the whole `/actions` domain — the documented way
12+
to expose custom server-side operations — was unreachable from the SDK, and
13+
every console hand-rolled `fetch` for it. The record id travels in the body,
14+
which both server URL shapes honor; the handler's own business failure comes
15+
back as `{ success: false, error }` rather than a thrown exception.
16+
17+
The route ledger flips all three `/actions` rows to `sdk` and the gap ratchet
18+
drops 27 → 24. Also takes the documentation-drift findings from the audit:
19+
the client README no longer documents six methods that do not exist,
20+
`CLIENT_SPEC_COMPLIANCE.md` is retired to a tombstone pointing at the
21+
CI-enforced ledger (its "FULLY COMPLIANT" verdict was measured against a
22+
route table nothing consumes), and the docs-site SDK page documents the new
23+
surface.

content/docs/api/client-sdk.mdx

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,15 +117,22 @@ The `@objectstack/client` SDK aims to implement the ObjectStack API protocol spe
117117
| **views** || 5 | UI view definitions |
118118
| **workflow** || 3 | Workflow state transitions |
119119
| **analytics** || 3 | Analytics queries |
120-
| **automation** || 1 | Automation triggers |
120+
| **automation** || 15 | Flow CRUD, trigger/execute, runs, screen-flow resume |
121+
| **actions** || 2 | Server-registered action handlers (`engine.registerAction`) |
121122
| **storage** || 2 | File upload & download |
122123
| **i18n** || 3 | Internationalization |
123124
| **notifications** | 🟡 | 7 | Legacy notification helpers; receipt/inbox cut-over pending |
124125
| **realtime** || 6 | Connection/subscription/presence calls (server transport is plugin-provided) |
125126
| **ai** || 3 | AI services (NLQ, suggest, insights) |
126127

127128
<Callout type="info">
128-
**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.
129+
**Coverage is CI-enforced, not hand-asserted**: the #3563 route ledger
130+
([`route-ledger.ts`](https://github.com/objectstack-ai/objectstack/blob/main/packages/runtime/src/route-ledger.ts))
131+
records the audited disposition of every server route, and conformance tests
132+
on both sides fail when a route lands without a reviewed disposition or the
133+
ledger names a client method that doesn't exist. Integration test
134+
specifications live in
135+
[`CLIENT_SERVER_INTEGRATION_TESTS.md`](https://github.com/objectstack-ai/objectstack/blob/main/packages/client/CLIENT_SERVER_INTEGRATION_TESTS.md).
129136
</Callout>
130137

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

342+
// Actions — Invoke server-registered action handlers
343+
// (engine.registerAction). The result envelope is the HANDLER's own
344+
// `{ success, data | error }` — a business failure comes back as
345+
// `success: false` + `error` instead of a thrown exception, so it can be
346+
// surfaced as a toast.
347+
const res = await client.actions.invoke('crm_lead', 'convert', {
348+
recordId,
349+
params: { create_opportunity: true },
350+
});
351+
if (!res.success) console.warn(res.error);
352+
// Global (object-less) actions dispatch to the wildcard handler:
353+
await client.actions.invokeGlobal('nightly_cleanup', { params: { dryRun: true } });
354+
335355
// Storage — File upload and management
336356
await client.storage.upload(fileData, 'user');
337357
await client.storage.getDownloadUrl('file-123');

packages/client/CLIENT_SPEC_COMPLIANCE.md

Lines changed: 26 additions & 361 deletions
Large diffs are not rendered by default.

packages/client/README.md

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

1717
## 🤖 AI Development Context
1818

@@ -47,7 +47,7 @@ async function main() {
4747
await client.connect();
4848

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

5353
// Save Metadata (New Feature)
@@ -93,10 +93,9 @@ async function main() {
9393
}
9494

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

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

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

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

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

227225
## Protocol Compliance
228226

229-
The `@objectstack/client` SDK implements all 13 API namespaces defined in the `@objectstack/spec` protocol specification:
230-
231-
| Namespace | Purpose | Status |
232-
|-----------|---------|:------:|
233-
| `discovery` | API version & capabilities detection ||
234-
| `meta` | Metadata operations (objects, plugins, etc.) ||
235-
| `data` | CRUD & query operations ||
236-
| `auth` | Authentication & user management ||
237-
| `packages` | Plugin/package lifecycle management ||
238-
| `views` | UI view definitions ||
239-
| `workflow` | Workflow state transitions ||
240-
| `analytics` | Analytics queries ||
241-
| `automation` | Automation triggers ||
242-
| `i18n` | Internationalization ||
243-
| `notifications` | Push notifications ||
244-
| `realtime` | WebSocket subscriptions ||
245-
| `ai` | AI services (NLQ, chat, insights) ||
246-
247-
For detailed compliance verification, see [CLIENT_SPEC_COMPLIANCE.md](./CLIENT_SPEC_COMPLIANCE.md).
227+
The SDK ships 20 top-level surfaces: `meta`, `data`, `auth`, `oauth`,
228+
`organizations`, `projects`, `packages`, `views`, `permissions`, `workflow`,
229+
`approvals`, `analytics`, `automation`, `actions`, `storage`, `i18n`,
230+
`notifications`, `realtime`, `ai`, and `events`.
231+
232+
Coverage against the server's actual route surface is no longer asserted by a
233+
hand-written table — it is audited and CI-enforced by the #3563 route ledger
234+
(`packages/runtime/src/route-ledger.ts` + its conformance tests, one half in
235+
runtime, one half in this package). See
236+
`docs/audits/2026-07-dispatcher-client-route-coverage.md` for the audit.
248237

249238
## Available Namespaces
250239

@@ -291,8 +280,10 @@ await client.permissions.getEffectivePermissions();
291280
await client.workflow.getConfig('approval');
292281
await client.workflow.getState('approval', recordId);
293282
await client.workflow.transition({ object: 'approval', recordId, transition: 'submit' });
294-
await client.workflow.approve({ object: 'approval', recordId });
295-
await client.workflow.reject({ object: 'approval', recordId, reason: 'Incomplete' });
283+
284+
// Approvals (approve/reject live here, not on workflow)
285+
await client.approvals.approve(requestId, 'LGTM');
286+
await client.approvals.reject(requestId, 'Incomplete');
296287

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

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

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `client.actions` — the SDK path to server-registered action handlers
5+
* (`POST /api/v1/actions/...`), closing the largest #3563 gap: before this
6+
* surface, the whole `/actions` domain was unreachable from the SDK.
7+
*/
8+
9+
import { describe, it, expect, vi } from 'vitest';
10+
import { ObjectStackClient } from './index';
11+
12+
function createMockClient(body: any, status = 200) {
13+
const fetchMock = vi.fn().mockResolvedValue({
14+
ok: status >= 200 && status < 300,
15+
status,
16+
statusText: status === 200 ? 'OK' : 'Error',
17+
json: async () => body,
18+
headers: new Headers()
19+
});
20+
const client = new ObjectStackClient({
21+
baseUrl: 'http://localhost:3000',
22+
fetch: fetchMock
23+
});
24+
return { client, fetchMock };
25+
}
26+
27+
describe('client.actions', () => {
28+
it('invoke POSTs to /api/v1/actions/:object/:action with recordId + params in the body', async () => {
29+
const { client, fetchMock } = createMockClient({
30+
success: true,
31+
data: { success: true, data: { converted: true } },
32+
});
33+
34+
const result = await client.actions.invoke('crm_lead', 'convert', {
35+
recordId: 'lead_1',
36+
params: { create_opportunity: true },
37+
});
38+
39+
const [url, init] = fetchMock.mock.calls[0];
40+
expect(String(url)).toBe('http://localhost:3000/api/v1/actions/crm_lead/convert');
41+
expect(init.method).toBe('POST');
42+
expect(JSON.parse(init.body)).toEqual({
43+
recordId: 'lead_1',
44+
params: { create_opportunity: true },
45+
});
46+
// unwrapResponse strips the transport envelope; the INNER envelope is
47+
// the action handler's own result.
48+
expect(result).toEqual({ success: true, data: { converted: true } });
49+
});
50+
51+
it('invoke URL-encodes object and action names', async () => {
52+
const { client, fetchMock } = createMockClient({ success: true, data: { success: true } });
53+
await client.actions.invoke('my object', 'do/thing');
54+
expect(String(fetchMock.mock.calls[0][0])).toBe(
55+
'http://localhost:3000/api/v1/actions/my%20object/do%2Fthing',
56+
);
57+
});
58+
59+
it('invoke defaults params to {} and omits recordId when not given', async () => {
60+
const { client, fetchMock } = createMockClient({ success: true, data: { success: true } });
61+
await client.actions.invoke('crm_lead', 'recalculate');
62+
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ params: {} });
63+
});
64+
65+
it('invokeGlobal targets the wildcard shape /actions/global/:action', async () => {
66+
const { client, fetchMock } = createMockClient({ success: true, data: { success: true } });
67+
await client.actions.invokeGlobal('nightly_cleanup', { params: { dryRun: true } });
68+
expect(String(fetchMock.mock.calls[0][0])).toBe(
69+
'http://localhost:3000/api/v1/actions/global/nightly_cleanup',
70+
);
71+
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ params: { dryRun: true } });
72+
});
73+
74+
it('surfaces the handler business failure without throwing (inner success:false)', async () => {
75+
// The dispatcher reports handler throws as HTTP 200 with an inner
76+
// `{ success:false, error }` — a toastable business failure, not a
77+
// transport error (http-dispatcher.handleActions catch branch).
78+
const { client } = createMockClient({
79+
success: true,
80+
data: { success: false, error: 'Lead is already converted' },
81+
});
82+
const result = await client.actions.invoke('crm_lead', 'convert', { recordId: 'lead_1' });
83+
expect(result.success).toBe(false);
84+
expect(result.error).toBe('Lead is already converted');
85+
});
86+
});

packages/client/src/index.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2355,6 +2355,61 @@ export class ObjectStackClient {
23552355
},
23562356
};
23572357

2358+
/**
2359+
* Server-registered Actions (#3563 gap closure)
2360+
*
2361+
* Dispatches named action handlers registered server-side via
2362+
* `engine.registerAction(objectName, actionName, handler)`. Until this
2363+
* surface existed, `POST /api/v1/actions/...` was entirely unreachable from
2364+
* the SDK — every console hand-rolled `fetch` for it (the largest functional
2365+
* hole found by the #3563 route audit).
2366+
*
2367+
* The path is fixed (`/api/v1/actions`), not discovery-routed: `actions` is
2368+
* not part of `ApiRoutesSchema`, so `getRoute()` cannot resolve it — same
2369+
* precedent as the `projects` surface's `/api/v1/cloud`.
2370+
*
2371+
* The dispatcher accepts the record id either in the URL or in the body;
2372+
* this client always sends it in the body (`{ recordId, params }`), which
2373+
* both server shapes honor. The HTTP envelope is
2374+
* `{ success, data: { success, data | error } }` — the OUTER envelope is
2375+
* transport success; the INNER `success:false` + `error` is the action
2376+
* handler's own (business) failure, deliberately not thrown so callers can
2377+
* surface it as a toast rather than a crash.
2378+
*/
2379+
actions = {
2380+
/**
2381+
* Invoke a server-registered action on an object.
2382+
* Falls back to the server's wildcard ('*') handler when no
2383+
* object-specific handler is registered.
2384+
*/
2385+
invoke: async <T = any>(
2386+
objectName: string,
2387+
actionName: string,
2388+
opts?: { recordId?: string; params?: Record<string, unknown> },
2389+
): Promise<{ success: boolean; data?: T; error?: string }> => {
2390+
const res = await this.fetch(
2391+
`${this.baseUrl}/api/v1/actions/${encodeURIComponent(objectName)}/${encodeURIComponent(actionName)}`,
2392+
{
2393+
method: 'POST',
2394+
body: JSON.stringify({ recordId: opts?.recordId, params: opts?.params ?? {} }),
2395+
},
2396+
);
2397+
return this.unwrapResponse(res) as Promise<{ success: boolean; data?: T; error?: string }>;
2398+
},
2399+
2400+
/**
2401+
* Invoke a global (object-less) action — the server's
2402+
* `POST /actions/global/:action` shape, dispatched to the wildcard
2403+
* handler registry.
2404+
*/
2405+
invokeGlobal: async <T = any>(
2406+
actionName: string,
2407+
opts?: { recordId?: string; params?: Record<string, unknown> },
2408+
): Promise<{ success: boolean; data?: T; error?: string }> => {
2409+
return this.actions.invoke<T>('global', actionName, opts);
2410+
},
2411+
};
2412+
23582413
/**
23592414
* Event Subscription API
23602415
* Provides real-time event subscriptions for metadata and data changes

packages/runtime/src/route-ledger.conformance.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,10 @@ describe('route ledger hygiene', () => {
8181
});
8282

8383
it('gap count only shrinks — update the ledger (and this number) when closing gaps', () => {
84-
// Ratchet, not aspiration: 27 audited gaps at #3563 PR-1. Closing one =
85-
// reclassify the entry to `sdk` AND lower this bound. Raising it demands
86-
// an explicit, reviewed decision to ship a new un-expressed route.
84+
// Ratchet, not aspiration: 27 audited gaps at #3563 PR-1; 24 after PR-2
85+
// (actions surface). Closing a gap = reclassify to `sdk` AND lower this
86+
// bound. Raising it demands an explicit, reviewed decision.
8787
const gaps = ROUTE_LEDGER.filter((e) => e.disposition === 'gap').length;
88-
expect(gaps).toBeLessThanOrEqual(27);
88+
expect(gaps).toBeLessThanOrEqual(24);
8989
});
9090
});

packages/runtime/src/route-ledger.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -189,10 +189,10 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [
189189
{ route: '* /mcp/**', domain: '/mcp', disposition: 'server-only', note: 'MCP Streamable HTTP transport — consumed by MCP clients, not this SDK' },
190190

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

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

0 commit comments

Comments
 (0)