diff --git a/.changeset/client-actions-surface.md b/.changeset/client-actions-surface.md new file mode 100644 index 0000000000..45bcdb7ef4 --- /dev/null +++ b/.changeset/client-actions-surface.md @@ -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. diff --git a/content/docs/api/client-sdk.mdx b/content/docs/api/client-sdk.mdx index fdfec3e004..596342ab7a 100644 --- a/content/docs/api/client-sdk.mdx +++ b/content/docs/api/client-sdk.mdx @@ -117,7 +117,8 @@ 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 | @@ -125,7 +126,13 @@ The `@objectstack/client` SDK aims to implement the ObjectStack API protocol spe | **ai** | ✅ | 3 | AI services (NLQ, suggest, insights) | -**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). --- @@ -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'); diff --git a/packages/client/CLIENT_SPEC_COMPLIANCE.md b/packages/client/CLIENT_SPEC_COMPLIANCE.md index 5f5da81231..3770749db7 100644 --- a/packages/client/CLIENT_SPEC_COMPLIANCE.md +++ b/packages/client/CLIENT_SPEC_COMPLIANCE.md @@ -1,361 +1,26 @@ -# @objectstack/client - Spec API Protocol Compliance Matrix - -## Overview - -This document verifies that `@objectstack/client` correctly implements all methods required by the `@objectstack/spec` API protocol specification. - -**Status**: ✅ **FULLY COMPLIANT** (as of 2026-02-09) - ---- - -## API Namespaces - -The spec defines 13 API namespaces via `DEFAULT_DISPATCHER_ROUTES` in `/packages/spec/src/api/dispatcher.zod.ts`: - -| Namespace | Service | Auth Required | Criticality | Client Implementation | -|-----------|---------|:-------------:|:-----------:|:--------------------:| -| `/api/v1/discovery` | metadata | ❌ | required | ✅ `connect()` | -| `/api/v1/meta` | metadata | ✅ | required | ✅ `meta.*` | -| `/api/v1/data` | data | ✅ | required | ✅ `data.*` | -| `/api/v1/auth` | auth | ✅ | required | ✅ `auth.*` | -| `/api/v1/packages` | metadata | ✅ | optional | ✅ `packages.*` | -| `/api/v1/ui` | ui | ✅ | optional | ✅ `views.*` | -| `/api/v1/workflow` | workflow | ✅ | optional | ✅ `workflow.*` | -| `/api/v1/analytics` | analytics | ✅ | optional | ✅ `analytics.*` | -| `/api/v1/automation` | automation | ✅ | optional | ✅ `automation.*` | -| `/api/v1/i18n` | i18n | ✅ | optional | ✅ `i18n.*` | -| `/api/v1/notifications` | notification | ✅ | optional | ✅ `notifications.*` | -| `/api/v1/realtime` | realtime | ✅ | optional | ✅ `realtime.*` | -| `/api/v1/ai` | ai | ✅ | optional | ✅ `ai.*` | - ---- - -## Method-by-Method Compliance - -### 1. Discovery & Metadata (`/api/v1/meta`, `/api/v1/discovery`) - -Protocol methods defined in `packages/spec/src/api/protocol.zod.ts`: - -| Spec Method | Request Schema | Response Schema | Client Method | Status | -|-------------|----------------|-----------------|---------------|:------:| -| Get Discovery | `GetDiscoveryRequestSchema` | `GetDiscoveryResponseSchema` | `connect()` | ✅ | -| Get Meta Types | `GetMetaTypesRequestSchema` | `GetMetaTypesResponseSchema` | `meta.getTypes()` | ✅ | -| Get Meta Items | `GetMetaItemsRequestSchema` | `GetMetaItemsResponseSchema` | `meta.getItems()` | ✅ | -| Get Meta Item | `GetMetaItemRequestSchema` | `GetMetaItemResponseSchema` | `meta.getItem()` | ✅ | -| Save Meta Item | `SaveMetaItemRequestSchema` | `SaveMetaItemResponseSchema` | `meta.saveItem()` | ✅ | -| Get Object (cached) | `MetadataCacheRequestSchema` | `MetadataCacheResponseSchema` | `meta.getCached()` | ✅ | -| Get Object (deprecated) | - | - | `meta.getObject()` | ✅ | - -**Notes:** -- `meta.getObject()` is marked deprecated in favor of `meta.getItem('object', name)` -- Cache support via ETag/If-None-Match headers is implemented in `getCached()` - ---- - -### 2. Data Operations (`/api/v1/data`) - -#### CRUD Operations - -| Spec Method | Request Schema | Response Schema | Client Method | Status | -|-------------|----------------|-----------------|---------------|:------:| -| Find Data | `FindDataRequestSchema` | `FindDataResponseSchema` | `data.find()` | ✅ | -| Query Data (Advanced) | `QueryDataRequestSchema` | `QueryDataResponseSchema` | `data.query()` | ✅ | -| Get Data | `GetDataRequestSchema` | `GetDataResponseSchema` | `data.get()` | ✅ | -| Create Data | `CreateDataRequestSchema` | `CreateDataResponseSchema` | `data.create()` | ✅ | -| Update Data | `UpdateDataRequestSchema` | `UpdateDataResponseSchema` | `data.update()` | ✅ | -| Delete Data | `DeleteDataRequestSchema` | `DeleteDataResponseSchema` | `data.delete()` | ✅ | - -#### Batch Operations - -| Spec Method | Request Schema | Response Schema | Client Method | Status | -|-------------|----------------|-----------------|---------------|:------:| -| Batch Operations | `BatchUpdateRequestSchema` | `BatchUpdateResponseSchema` | `data.batch()` | ✅ | -| Create Many | `CreateManyRequestSchema` | `CreateManyResponseSchema` | `data.createMany()` | ✅ | -| Update Many | `UpdateManyRequestSchema` | `UpdateManyResponseSchema` | `data.updateMany()` | ✅ | -| Delete Many | `DeleteManyRequestSchema` | `DeleteManyResponseSchema` | `data.deleteMany()` | ✅ | - -**Notes:** -- `data.find()` supports simplified query parameters (filters, sort, pagination) -- `data.query()` supports full ObjectQL AST for complex queries -- Batch operations support `BatchOptions` for transaction control - ---- - -### 3. Authentication (`/api/v1/auth`) - -| Spec Method | Request Schema | Response Schema | Client Method | Status | -|-------------|----------------|-----------------|---------------|:------:| -| Login | `LoginRequestSchema` | `SessionResponseSchema` | `auth.login()` | ✅ | -| Register | `RegisterRequestSchema` | `SessionResponseSchema` | `auth.register()` | ✅ | -| Logout | `LogoutRequestSchema` | `LogoutResponseSchema` | `auth.logout()` | ✅ | -| Refresh Token | `RefreshTokenRequestSchema` | `SessionResponseSchema` | `auth.refreshToken()` | ✅ | -| Get Current User | `GetCurrentUserRequestSchema` | `GetCurrentUserResponseSchema` | `auth.me()` | ✅ | - ---- - -### 4. Package Management (`/api/v1/packages`) - -| Spec Method | Request Schema | Response Schema | Client Method | Status | -|-------------|----------------|-----------------|---------------|:------:| -| List Packages | `ListPackagesRequestSchema` | `ListPackagesResponseSchema` | `packages.list()` | ✅ | -| Get Package | `GetPackageRequestSchema` | `GetPackageResponseSchema` | `packages.get()` | ✅ | -| Install Package | `InstallPackageRequestSchema` | `InstallPackageResponseSchema` | `packages.install()` | ✅ | -| Uninstall Package | `UninstallPackageRequestSchema` | `UninstallPackageResponseSchema` | `packages.uninstall()` | ✅ | -| Enable Package | `EnablePackageRequestSchema` | `EnablePackageResponseSchema` | `packages.enable()` | ✅ | -| Disable Package | `DisablePackageRequestSchema` | `DisablePackageResponseSchema` | `packages.disable()` | ✅ | - ---- - -### 5. View Management (`/api/v1/ui`) - -| Spec Method | Request Schema | Response Schema | Client Method | Status | -|-------------|----------------|-----------------|---------------|:------:| -| List Views | `ListViewsRequestSchema` | `ListViewsResponseSchema` | `views.list()` | ✅ | -| Get View | `GetViewRequestSchema` | `GetViewResponseSchema` | `views.get()` | ✅ | -| Create View | `CreateViewRequestSchema` | `CreateViewResponseSchema` | `views.create()` | ✅ | -| Update View | `UpdateViewRequestSchema` | `UpdateViewResponseSchema` | `views.update()` | ✅ | -| Delete View | `DeleteViewRequestSchema` | `DeleteViewResponseSchema` | `views.delete()` | ✅ | - ---- - -### 6. Permissions (`/api/v1/auth/permissions`) - -| Spec Method | Request Schema | Response Schema | Client Method | Status | -|-------------|----------------|-----------------|---------------|:------:| -| Check Permission | `CheckPermissionRequestSchema` | `CheckPermissionResponseSchema` | `permissions.check()` | ✅ | -| Get Object Permissions | `GetObjectPermissionsRequestSchema` | `GetObjectPermissionsResponseSchema` | `permissions.getObjectPermissions()` | ✅ | -| Get Effective Permissions | `GetEffectivePermissionsRequestSchema` | `GetEffectivePermissionsResponseSchema` | `permissions.getEffectivePermissions()` | ✅ | - -**Notes:** -- Permission endpoints are served under `/api/v1/auth` per spec's `plugin-rest-api.zod.ts` -- Supports action types: `create`, `read`, `edit`, `delete`, `transfer`, `restore`, `purge` -- `check()` uses POST method as per spec -- `getObjectPermissions()` and `getEffectivePermissions()` use GET methods - ---- - -### 7. Workflow (`/api/v1/workflow`) - -| Spec Method | Request Schema | Response Schema | Client Method | Status | -|-------------|----------------|-----------------|---------------|:------:| -| Get Workflow Config | `GetWorkflowConfigRequestSchema` | `GetWorkflowConfigResponseSchema` | `workflow.getConfig()` | ✅ | -| Get Workflow State | `GetWorkflowStateRequestSchema` | `GetWorkflowStateResponseSchema` | `workflow.getState()` | ✅ | -| Workflow Transition | `WorkflowTransitionRequestSchema` | `WorkflowTransitionResponseSchema` | `workflow.transition()` | ✅ | -| Workflow Approve | `WorkflowApproveRequestSchema` | `WorkflowApproveResponseSchema` | `workflow.approve()` | ✅ | -| Workflow Reject | `WorkflowRejectRequestSchema` | `WorkflowRejectResponseSchema` | `workflow.reject()` | ✅ | - ---- - -### 8. Realtime (`/api/v1/realtime`) - -| Spec Method | Request Schema | Response Schema | Client Method | Status | -|-------------|----------------|-----------------|---------------|:------:| -| Connect | `RealtimeConnectRequestSchema` | `RealtimeConnectResponseSchema` | `realtime.connect()` | ✅ | -| Disconnect | `RealtimeDisconnectRequestSchema` | `RealtimeDisconnectResponseSchema` | `realtime.disconnect()` | ✅ | -| Subscribe | `RealtimeSubscribeRequestSchema` | `RealtimeSubscribeResponseSchema` | `realtime.subscribe()` | ✅ | -| Unsubscribe | `RealtimeUnsubscribeRequestSchema` | `RealtimeUnsubscribeResponseSchema` | `realtime.unsubscribe()` | ✅ | -| Set Presence | `SetPresenceRequestSchema` | `SetPresenceResponseSchema` | `realtime.setPresence()` | ✅ | -| Get Presence | `GetPresenceRequestSchema` | `GetPresenceResponseSchema` | `realtime.getPresence()` | ✅ | - ---- - -### 9. Notifications (`/api/v1/notifications`) - -| Spec Method | Request Schema | Response Schema | Client Method | Status | -|-------------|----------------|-----------------|---------------|:------:| -| Register Device | `RegisterDeviceRequestSchema` | `RegisterDeviceResponseSchema` | `notifications.registerDevice()` | ✅ | -| Unregister Device | `UnregisterDeviceRequestSchema` | `UnregisterDeviceResponseSchema` | `notifications.unregisterDevice()` | ✅ | -| Get Preferences | `GetNotificationPreferencesRequestSchema` | `GetNotificationPreferencesResponseSchema` | `notifications.getPreferences()` | ✅ | -| Update Preferences | `UpdateNotificationPreferencesRequestSchema` | `UpdateNotificationPreferencesResponseSchema` | `notifications.updatePreferences()` | ✅ | -| List Notifications | `ListNotificationsRequestSchema` | `ListNotificationsResponseSchema` | `notifications.list()` | ✅ | -| Mark Read | `MarkNotificationsReadRequestSchema` | `MarkNotificationsReadResponseSchema` | `notifications.markRead()` | ✅ | -| Mark All Read | `MarkAllNotificationsReadRequestSchema` | `MarkAllNotificationsReadResponseSchema` | `notifications.markAllRead()` | ✅ | - ---- - -### 10. AI Services (`/api/v1/ai`) - -| Spec Method | Request Schema | Response Schema | Client Method | Status | -|-------------|----------------|-----------------|---------------|:------:| -| Natural Language Query | `AiNlqRequestSchema` | `AiNlqResponseSchema` | `ai.nlq()` | ✅ | -| AI Chat | `AiChatRequestSchema` | `AiChatResponseSchema` | `ai.chat()` | ✅ | -| AI Suggestions | `AiSuggestRequestSchema` | `AiSuggestResponseSchema` | `ai.suggest()` | ✅ | -| AI Insights | `AiInsightsRequestSchema` | `AiInsightsResponseSchema` | `ai.insights()` | ✅ | - ---- - -### 11. Automation (`/api/v1/automation`) - -| Spec Method | Request Schema | Response Schema | Client Method | Status | -|-------------|----------------|-----------------|---------------|:------:| -| Trigger Automation | `AutomationTriggerRequestSchema` | `AutomationTriggerResponseSchema` | `automation.trigger()` | ✅ | - -**Notes:** -- Schema defined in `packages/client/src/index.ts` (lines 50-59) -- Allows triggering named automations with arbitrary payloads -- Method signature: `trigger(triggerName: string, payload: any)` - ---- - -### 12. Internationalization (`/api/v1/i18n`) - -| Spec Method | Request Schema | Response Schema | Client Method | Status | -|-------------|----------------|-----------------|---------------|:------:| -| Get Locales | `GetLocalesRequestSchema` | `GetLocalesResponseSchema` | `i18n.getLocales()` | ✅ | -| Get Translations | `GetTranslationsRequestSchema` | `GetTranslationsResponseSchema` | `i18n.getTranslations()` | ✅ | -| Get Field Labels | `GetFieldLabelsRequestSchema` | `GetFieldLabelsResponseSchema` | `i18n.getFieldLabels()` | ✅ | - ---- - -### 13. Analytics (`/api/v1/analytics`) - -| Spec Method | Request Schema | Response Schema | Client Method | Status | -|-------------|----------------|-----------------|---------------|:------:| -| Analytics Query | `AnalyticsQueryRequestSchema` | `AnalyticsResultResponseSchema` | `analytics.query()` | ✅ | -| Get Analytics Meta | `GetAnalyticsMetaRequestSchema` | `AnalyticsMetadataResponseSchema` | `analytics.meta(cube)` | ✅ | - ---- - -## Storage Operations - -The client implements file storage operations though they're not explicitly defined as a separate namespace in DEFAULT_DISPATCHER_ROUTES: - -| Operation | Client Method | Status | -|-----------|---------------|:------:| -| Get Presigned URL | `storage.getPresignedUrl()` | ✅ | -| Upload File | `storage.upload()` | ✅ | -| Complete Upload | `storage.completeUpload()` | ✅ | -| Get Download URL | `storage.getDownloadUrl()` | ✅ | - -**Note:** Storage operations use the `/api/v1/storage` prefix though not in DEFAULT_DISPATCHER_ROUTES. This is expected as storage can be plugin-provided. - ---- - -## Hub Operations - -The client implements hub connectivity operations: - -| Operation | Client Method | Status | -|-----------|---------------|:------:| -| Connect to Hub | `hub.connect()` | ✅ | - ---- - -## Architecture & Implementation Notes - -### Request/Response Envelope - -The client correctly implements the standard response envelope pattern: -- All server responses wrapped in `{ success: boolean, data: T, meta?: any }` -- `unwrapResponse()` helper extracts inner `data` payload -- Error responses use `ApiErrorSchema` with `StandardErrorCode` enum - -### Authentication - -- JWT token passed via `Authorization: Bearer ` header -- Token configurable via `ClientConfig.token` -- `auth.login()` returns session with token for subsequent requests - -### HTTP Methods - -Client uses correct HTTP verbs per REST conventions: -- `GET` for read operations (find, get, list) -- `POST` for create and complex queries (create, query, batch operations) -- `PATCH` for updates (update) -- `PUT` for save/upsert (saveItem) -- `DELETE` for deletions (delete) - -### Query Strategies - -The client supports three query approaches: -1. **Simplified** (`data.find()`) - Query params for basic filters -2. **AST** (`data.query()`) - Full ObjectQL AST via POST body -3. **Direct** (`data.get()`) - Retrieve by ID - -### Route Resolution - -- `getRoute(namespace)` helper resolves API prefix from discovery info -- Fallback to `/api/v1/{namespace}` if discovery not available -- Supports custom base URLs via `ClientConfig.baseUrl` - ---- - -## Compliance Summary - -✅ **All 13 API namespaces implemented** -✅ **All required core services (discovery, meta, data, auth) implemented** -✅ **All optional services implemented** -✅ **95+ protocol methods implemented** -✅ **Correct request/response schema usage** -✅ **Proper HTTP verbs and URL patterns** -✅ **Authentication support** -✅ **Batch operations support** -✅ **Cache support (ETag, If-None-Match)** - ---- - -## Testing Requirements - -To verify client-server integration, tests should cover: - -1. **Connection & Discovery** - - ✓ Standard discovery via `.well-known/objectstack` - - ✓ Fallback discovery via `/api/v1` - - ✓ Capability detection - - ✓ Route mapping - -2. **Authentication Flow** - - ✓ Login (email/password, magic link, social) - - ✓ Token management - - ✓ Session refresh - - ✓ Logout - -3. **CRUD Operations** - - ✓ Create single/many records - - ✓ Read with filters, pagination, sorting - - ✓ Update single/many records - - ✓ Delete single/many records - - ✓ Batch mixed operations - -4. **Advanced Features** - - ✓ Complex queries (ObjectQL AST) - - ✓ Metadata caching (ETag) - - ✓ Workflow transitions - - ✓ Permission checks - - ✓ Realtime subscriptions - - ✓ File uploads/downloads - - ✓ AI operations - -5. **Error Handling** - - ✓ Network errors - - ✓ 4xx client errors - - ✓ 5xx server errors - - ✓ Standard error codes - - ✓ Validation errors - -See `CLIENT_SERVER_INTEGRATION_TESTS.md` for detailed test specifications. - ---- - -## Version Compatibility - -| Package | Version | Compatibility | -|---------|---------|---------------| -| `@objectstack/spec` | Latest | ✅ Fully compatible | -| `@objectstack/client` | Latest | ✅ Implements all protocols | -| `@objectstack/core` | Latest | ✅ Required dependency | - ---- - -## Related Documentation - -- [Spec Protocol Map](../spec/PROTOCOL_MAP.md) -- [REST API Plugin](../spec/REST_API_PLUGIN.md) -- [Client README](./README.md) -- [Integration Test Suite](./CLIENT_SERVER_INTEGRATION_TESTS.md) - ---- - -**Last Updated:** 2026-02-09 -**Reviewed By:** GitHub Copilot Agent -**Status:** ✅ Verified Complete +# @objectstack/client — Spec Compliance Matrix (RETIRED) + +> **Retired 2026-07-27 by the #3563 route audit.** This document's +> "✅ FULLY COMPLIANT" verdict (last verified 2026-02-09) was measured against +> `DEFAULT_DISPATCHER_ROUTES` in `@objectstack/spec` — a table nothing in the +> runtime consumes, which lists two domains that do not exist (`/workflow` +> as a dispatcher route, `/realtime`) and omits eight that do (`/keys`, +> `/mcp`, `/mcp/skill`, `/actions`, `/security`, `/share-links`, `/ready`, +> `/openapi.json`). Measured against the server's real route surface, the +> audit found 27 routes with no SDK expression on the day this file still +> claimed full compliance. +> +> Hand-maintained compliance tables drift; this one is not coming back. + +Coverage is now asserted by code, in CI, from both sides: + +- **Inventory:** `packages/runtime/src/route-ledger.ts` — the audited + disposition of every dispatcher route. +- **Guard:** `packages/runtime/src/route-ledger.conformance.test.ts` + (dispatcher domains ↔ ledger, gap ratchet) and + `packages/client/src/route-ledger-coverage.test.ts` (every ledger-named + client method must exist). +- **Findings & follow-ups:** + `docs/audits/2026-07-dispatcher-client-route-coverage.md`. + +This file is kept only so inbound links don't break. diff --git a/packages/client/README.md b/packages/client/README.md index f8c9fde7fb..c717cf7b78 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -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 @@ -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) @@ -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: { @@ -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(object, options)`: Advanced query with filtering, sorting, selection. @@ -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: @@ -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 @@ -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' }); @@ -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 }); diff --git a/packages/client/src/actions-surface.test.ts b/packages/client/src/actions-surface.test.ts new file mode 100644 index 0000000000..3f2a627844 --- /dev/null +++ b/packages/client/src/actions-surface.test.ts @@ -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'); + }); +}); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 778e1caeba..cac6a9c35e 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -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 ( + objectName: string, + actionName: string, + opts?: { recordId?: string; params?: Record }, + ): 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 ( + actionName: string, + opts?: { recordId?: string; params?: Record }, + ): Promise<{ success: boolean; data?: T; error?: string }> => { + return this.actions.invoke('global', actionName, opts); + }, + }; + /** * Event Subscription API * Provides real-time event subscriptions for metadata and data changes diff --git a/packages/runtime/src/route-ledger.conformance.test.ts b/packages/runtime/src/route-ledger.conformance.test.ts index ff055b3a93..bfdfc4de63 100644 --- a/packages/runtime/src/route-ledger.conformance.test.ts +++ b/packages/runtime/src/route-ledger.conformance.test.ts @@ -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); }); }); diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index 99352d1c66..f5ae2337b4 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -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' },