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
21 changes: 21 additions & 0 deletions .changeset/client-meta-automation-descriptors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@objectstack/client": minor
"@objectstack/runtime": patch
---

feat(client): the final six route-audit gaps — meta drafts/published/FSM + automation descriptors (#3563 PR-5)

- `meta.getPublished(type, name)` — the published version of a metadata item
(ADR-0033; compound names pass through unencoded, matching `getItem`).
- `meta.listDrafts({ packageId?, type? })` — pending drafts the active-only
lists hide.
- `meta.getLegalNextStates(object, field, from?)` — ADR-0020 FSM
introspection ("from here, where can this record go?").
- `automation.listActions({ paradigm?, source?, category? })` /
`automation.listConnectors({ type? })` — the ADR-0018/0022 descriptor
registries backing the Studio designer's pickers.
- `automation.getRuntimeStatus()` — per-flow enabled/bound engine state.

With these, the #3563 gap ratchet reaches **0** (from 27): every dispatcher
route that should be SDK-expressible is, and the conformance guard keeps it
that way.
4 changes: 2 additions & 2 deletions content/docs/api/client-sdk.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -109,15 +109,15 @@ The `@objectstack/client` SDK aims to implement the ObjectStack API protocol spe
| Namespace | Status | Methods | Purpose |
|:----------|:------:|:--------|:--------|
| **discovery** | ✅ | 1 | API version & capabilities detection |
| **meta** | ✅ | 7 | Metadata operations (objects, views, plugins) |
| **meta** | ✅ | 11 | Metadata read/write, published versions & drafts (ADR-0033), FSM introspection (ADR-0020) |
| **data** | ✅ | 10 | CRUD & query operations |
| **auth** | ✅ | 5 | Authentication & user management |
| **permissions** | ✅ | 3 | Access control checks |
| **packages** | ✅ | 17 | Package lifecycle: install/enable, drafts (ADR-0033), commits & rollback (ADR-0067), export/duplicate (ADR-0070) |
| **views** | ✅ | 5 | UI view definitions |
| **workflow** | ✅ | 3 | Workflow state transitions |
| **analytics** | ✅ | 3 | Analytics queries |
| **automation** | ✅ | 15 | Flow CRUD, trigger/execute, runs, screen-flow resume |
| **automation** | ✅ | 18 | Flow CRUD, trigger/execute, runs, screen-flow resume, descriptor/status registries |
| **actions** | ✅ | 2 | Server-registered action handlers (`engine.registerAction`) |
| **keys** | ✅ | 1 | API key minting (one-time secret) |
| **shareLinks** | ✅ | 3 | Record share-link management |
Expand Down
81 changes: 81 additions & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,48 @@ export class ObjectStackClient {
const route = this.getRoute('ui');
const res = await this.fetch(`${this.baseUrl}${route}/view/${object}?type=${type}`);
return this.unwrapResponse(res);
},

/* [#3563 PR-5] The three meta routes that had no SDK expression. */

/**
* ADR-0033: the published version of a metadata item. Compound names are
* passed through unencoded (e.g. `getPublished('lead', 'views/all_leads')`),
* matching how `getItem` addresses sub-resources.
*/
getPublished: async (type: string, name: string) => {
const route = this.getRoute('metadata');
const res = await this.fetch(`${this.baseUrl}${route}/${type}/${name}/published`);
return this.unwrapResponse<any>(res);
},

/**
* ADR-0033: pending drafts — metadata authored (e.g. by an AI) but not
* yet published, which the active-only item lists hide.
*/
listDrafts: async (opts?: { packageId?: string; type?: string }) => {
const route = this.getRoute('metadata');
const params = new URLSearchParams();
if (opts?.packageId) params.set('packageId', opts.packageId);
if (opts?.type) params.set('type', opts.type);
const qs = params.toString();
const res = await this.fetch(`${this.baseUrl}${route}/_drafts${qs ? `?${qs}` : ''}`);
return this.unwrapResponse<any>(res);
},

/**
* ADR-0020 D3.3 FSM introspection: the legal next states for `field`
* from state `from`, per the object's `state_machine` validation rule.
* `next` is `null` when no FSM governs the field (or `from` is omitted),
* `[]` for a declared dead-end state.
*/
getLegalNextStates: async (object: string, field: string, from?: string) => {
const route = this.getRoute('metadata');
const qs = from !== undefined ? `?from=${encodeURIComponent(from)}` : '';
const res = await this.fetch(
`${this.baseUrl}${route}/objects/${encodeURIComponent(object)}/state/${encodeURIComponent(field)}${qs}`,
);
return this.unwrapResponse<{ object: string; field: string; from: string | null; next: string[] | null }>(res);
}
};

Expand Down Expand Up @@ -2359,6 +2401,45 @@ export class ObjectStackClient {
/**
* Enable or disable a flow
*/
/* [#3563 PR-5] The three descriptor/status routes that had no SDK
* expression — they back the Studio designer's pickers and badges. */

/**
* ADR-0018: registered action-node descriptors, optionally filtered by
* `paradigm` / `source` / `category`. Empty registry → `{ actions: [], total: 0 }`.
*/
listActions: async (opts?: { paradigm?: string; source?: string; category?: string }): Promise<{ actions: any[]; total: number }> => {
const route = this.getRoute('automation');
const params = new URLSearchParams();
if (opts?.paradigm) params.set('paradigm', opts.paradigm);
if (opts?.source) params.set('source', opts.source);
if (opts?.category) params.set('category', opts.category);
const qs = params.toString();
const res = await this.fetch(`${this.baseUrl}${route}/actions${qs ? `?${qs}` : ''}`);
return this.unwrapResponse(res);
},

/**
* ADR-0022: registered connector descriptors (populated by connector
* plugins), optionally filtered by `type`.
*/
listConnectors: async (opts?: { type?: string }): Promise<{ connectors: any[]; total: number }> => {
const route = this.getRoute('automation');
const qs = opts?.type ? `?type=${encodeURIComponent(opts.type)}` : '';
const res = await this.fetch(`${this.baseUrl}${route}/connectors${qs}`);
return this.unwrapResponse(res);
},

/**
* Runtime enable/bound state for every flow — engine state, not
* persisted metadata (backs the Studio's Automations status badges).
*/
getRuntimeStatus: async (): Promise<{ flows: Array<{ name: string; enabled: boolean; bound: boolean }>; total: number }> => {
const route = this.getRoute('automation');
const res = await this.fetch(`${this.baseUrl}${route}/_status`);
return this.unwrapResponse(res);
},

toggle: async (name: string, enabled: boolean): Promise<{ name: string; enabled: boolean }> => {
const route = this.getRoute('automation');
const res = await this.fetch(`${this.baseUrl}${route}/${name}/toggle`, {
Expand Down
93 changes: 93 additions & 0 deletions packages/client/src/meta-automation-descriptors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The final six #3563 gap closures (PR-5): meta published/drafts/FSM and the
* automation descriptor/status routes. With these, every should-be-SDK
* dispatcher route has an SDK expression and the ledger's gap ratchet is 0.
*/

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.meta (#3563 PR-5)', () => {
it('getPublished passes compound names through unencoded', async () => {
const { client, fetchMock } = createMockClient({ success: true, data: { name: 'all_leads' } });
await client.meta.getPublished('lead', 'views/all_leads');
expect(String(fetchMock.mock.calls[0][0])).toBe(
'http://localhost:3000/api/v1/meta/lead/views/all_leads/published',
);
});

it('listDrafts builds the packageId/type query and omits empties', async () => {
const { client, fetchMock } = createMockClient({ success: true, data: { drafts: [] } });
await client.meta.listDrafts({ packageId: 'com.acme.crm', type: 'object' });
expect(String(fetchMock.mock.calls[0][0])).toBe(
'http://localhost:3000/api/v1/meta/_drafts?packageId=com.acme.crm&type=object',
);
await client.meta.listDrafts();
expect(String(fetchMock.mock.calls[1][0])).toBe('http://localhost:3000/api/v1/meta/_drafts');
});

it('getLegalNextStates hits the FSM route and forwards from=', async () => {
const { client, fetchMock } = createMockClient({
success: true,
data: { object: 'crm_lead', field: 'status', from: 'new', next: ['contacted'] },
});
const out = await client.meta.getLegalNextStates('crm_lead', 'status', 'new');
expect(String(fetchMock.mock.calls[0][0])).toBe(
'http://localhost:3000/api/v1/meta/objects/crm_lead/state/status?from=new',
);
expect(out.next).toEqual(['contacted']);

// Omitted `from` → no query; the server answers next: null.
await client.meta.getLegalNextStates('crm_lead', 'status');
expect(String(fetchMock.mock.calls[1][0])).toBe(
'http://localhost:3000/api/v1/meta/objects/crm_lead/state/status',
);
});
});

describe('client.automation descriptors (#3563 PR-5)', () => {
it('listActions forwards the paradigm/source/category filters', async () => {
const { client, fetchMock } = createMockClient({ success: true, data: { actions: [], total: 0 } });
await client.automation.listActions({ paradigm: 'flow', category: 'crud' });
expect(String(fetchMock.mock.calls[0][0])).toBe(
'http://localhost:3000/api/v1/automation/actions?paradigm=flow&category=crud',
);
await client.automation.listActions();
expect(String(fetchMock.mock.calls[1][0])).toBe('http://localhost:3000/api/v1/automation/actions');
});

it('listConnectors forwards the type filter', async () => {
const { client, fetchMock } = createMockClient({ success: true, data: { connectors: [], total: 0 } });
await client.automation.listConnectors({ type: 'rest' });
expect(String(fetchMock.mock.calls[0][0])).toBe(
'http://localhost:3000/api/v1/automation/connectors?type=rest',
);
});

it('getRuntimeStatus GETs the underscore-guarded _status route', async () => {
const { client, fetchMock } = createMockClient({
success: true,
data: { flows: [{ name: 'f1', enabled: true, bound: true }], total: 1 },
});
const out = await client.automation.getRuntimeStatus();
expect(String(fetchMock.mock.calls[0][0])).toBe('http://localhost:3000/api/v1/automation/_status');
expect(out.flows[0].bound).toBe(true);
});
});
5 changes: 3 additions & 2 deletions packages/runtime/src/route-ledger.conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,11 @@ 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; 24 after PR-2
// (actions surface); 17 after PR-3 (keys / share-links / security);
// 6 after PR-4 (packages lifecycle).
// 6 after PR-4 (packages lifecycle); 0 after PR-5 (meta + automation
// descriptors) — every should-be-SDK dispatcher route is now expressed.
// 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(6);
expect(gaps).toBeLessThanOrEqual(0);
});
});
12 changes: 6 additions & 6 deletions packages/runtime/src/route-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,9 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [
note: 'legacy verb-first shape; duplicates execute() against a different URL — candidates for consolidation' },
{ route: 'GET /automation', domain: '/automation', disposition: 'sdk', client: 'automation.list' },
{ route: 'POST /automation', domain: '/automation', disposition: 'sdk', client: 'automation.create' },
{ route: 'GET /automation/actions', domain: '/automation', disposition: 'gap', note: 'ADR-0018 action descriptors — no client expression' },
{ route: 'GET /automation/connectors', domain: '/automation', disposition: 'gap', note: 'ADR-0022 connector descriptors — no client expression' },
{ route: 'GET /automation/_status', domain: '/automation', disposition: 'gap', note: 'per-flow runtime state — no client expression' },
{ route: 'GET /automation/actions', domain: '/automation', disposition: 'sdk', client: 'automation.listActions' },
{ route: 'GET /automation/connectors', domain: '/automation', disposition: 'sdk', client: 'automation.listConnectors' },
{ route: 'GET /automation/_status', domain: '/automation', disposition: 'sdk', client: 'automation.getRuntimeStatus' },
{ route: 'POST /automation/:name/trigger', domain: '/automation', disposition: 'sdk', client: 'automation.execute' },
{ route: 'POST /automation/:name/toggle', domain: '/automation', disposition: 'sdk', client: 'automation.toggle' },
{ route: 'POST /automation/:name/runs/:runId/resume', domain: '/automation', disposition: 'sdk', client: 'automation.resume' },
Expand All @@ -172,9 +172,9 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [
{ route: 'GET /meta/:type', domain: '/meta', disposition: 'sdk', client: 'meta.getItems' },
{ route: 'GET /meta/:type/:name', domain: '/meta', disposition: 'sdk', client: 'meta.getItem' },
{ route: 'PUT /meta/:type/:name', domain: '/meta', disposition: 'sdk', client: 'meta.saveItem' },
{ route: 'GET /meta/:type/:name/published', domain: '/meta', disposition: 'gap', note: 'ADR-0033 published version — no client expression' },
{ route: 'GET /meta/_drafts', domain: '/meta', disposition: 'gap', note: 'ADR-0033 pending drafts — no client expression' },
{ route: 'GET /meta/objects/:name/state/:field', domain: '/meta', disposition: 'gap', note: 'ADR-0020 legal next FSM states — no client expression' },
{ route: 'GET /meta/:type/:name/published', domain: '/meta', disposition: 'sdk', client: 'meta.getPublished' },
{ route: 'GET /meta/_drafts', domain: '/meta', disposition: 'sdk', client: 'meta.listDrafts' },
{ route: 'GET /meta/objects/:name/state/:field', domain: '/meta', disposition: 'sdk', client: 'meta.getLegalNextStates' },

// ── data (legacy chain) ───────────────────────────────────────────────────
{ route: 'POST /data/:object/query', domain: '/data', disposition: 'sdk', client: 'data.query' },
Expand Down