Skip to content

Commit 0164f40

Browse files
os-zhuangclaude
andauthored
feat(client): the final six route-audit gaps — meta drafts/published/FSM + automation descriptors (#3563 PR-5) (#3579)
The gap ratchet reaches ZERO (from 27 at the start of the audit): every dispatcher route that should be SDK-expressible now is, and the conformance guard keeps it that way. meta (shapes mirrored from domains/meta.ts): - getPublished(type, name) — GET /meta/:type/:name/published (ADR-0033; compound names pass through unencoded, matching getItem) - listDrafts({packageId?, type?}) — GET /meta/_drafts (pending drafts the active-only lists hide — e.g. what an AI authored but nobody published) - getLegalNextStates(object, field, from?) — GET /meta/objects/:name/state/:field (ADR-0020 D3.3 FSM introspection; next=null when no FSM / from omitted, [] for a declared dead-end) automation (shapes mirrored from domains/automation.ts): - listActions({paradigm?, source?, category?}) — GET /automation/actions (ADR-0018 descriptors backing the designer's action picker) - listConnectors({type?}) — GET /automation/connectors (ADR-0022) - getRuntimeStatus() — GET /automation/_status (per-flow enabled/bound engine state behind the Studio badges) Ledger: the last six rows flip to sdk; ratchet 6 → 0. Docs-site meta and automation rows updated to the real method counts. Note: main's ADR-0076 extraction moved /meta /data /actions /mcp into the domain registry since PR-1; the guard absorbed the migration with no edits — registry-enumerated domains and pinned legacy prefixes are checked as one set, which was the design intent. Verified: client 146 passed (6 new), ledger guard green on the new main, eslint clean. Claude-Session: https://claude.ai/code/session_01LX9ut3MK3KykE11S9bJmv5 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 20cb232 commit 0164f40

6 files changed

Lines changed: 206 additions & 10 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/client": minor
3+
"@objectstack/runtime": patch
4+
---
5+
6+
feat(client): the final six route-audit gaps — meta drafts/published/FSM + automation descriptors (#3563 PR-5)
7+
8+
- `meta.getPublished(type, name)` — the published version of a metadata item
9+
(ADR-0033; compound names pass through unencoded, matching `getItem`).
10+
- `meta.listDrafts({ packageId?, type? })` — pending drafts the active-only
11+
lists hide.
12+
- `meta.getLegalNextStates(object, field, from?)` — ADR-0020 FSM
13+
introspection ("from here, where can this record go?").
14+
- `automation.listActions({ paradigm?, source?, category? })` /
15+
`automation.listConnectors({ type? })` — the ADR-0018/0022 descriptor
16+
registries backing the Studio designer's pickers.
17+
- `automation.getRuntimeStatus()` — per-flow enabled/bound engine state.
18+
19+
With these, the #3563 gap ratchet reaches **0** (from 27): every dispatcher
20+
route that should be SDK-expressible is, and the conformance guard keeps it
21+
that way.

content/docs/api/client-sdk.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,15 +109,15 @@ The `@objectstack/client` SDK aims to implement the ObjectStack API protocol spe
109109
| Namespace | Status | Methods | Purpose |
110110
|:----------|:------:|:--------|:--------|
111111
| **discovery** || 1 | API version & capabilities detection |
112-
| **meta** || 7 | Metadata operations (objects, views, plugins) |
112+
| **meta** || 11 | Metadata read/write, published versions & drafts (ADR-0033), FSM introspection (ADR-0020) |
113113
| **data** || 10 | CRUD & query operations |
114114
| **auth** || 5 | Authentication & user management |
115115
| **permissions** || 3 | Access control checks |
116116
| **packages** || 17 | Package lifecycle: install/enable, drafts (ADR-0033), commits & rollback (ADR-0067), export/duplicate (ADR-0070) |
117117
| **views** || 5 | UI view definitions |
118118
| **workflow** || 3 | Workflow state transitions |
119119
| **analytics** || 3 | Analytics queries |
120-
| **automation** || 15 | Flow CRUD, trigger/execute, runs, screen-flow resume |
120+
| **automation** || 18 | Flow CRUD, trigger/execute, runs, screen-flow resume, descriptor/status registries |
121121
| **actions** || 2 | Server-registered action handlers (`engine.registerAction`) |
122122
| **keys** || 1 | API key minting (one-time secret) |
123123
| **shareLinks** || 3 | Record share-link management |

packages/client/src/index.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,48 @@ export class ObjectStackClient {
523523
const route = this.getRoute('ui');
524524
const res = await this.fetch(`${this.baseUrl}${route}/view/${object}?type=${type}`);
525525
return this.unwrapResponse(res);
526+
},
527+
528+
/* [#3563 PR-5] The three meta routes that had no SDK expression. */
529+
530+
/**
531+
* ADR-0033: the published version of a metadata item. Compound names are
532+
* passed through unencoded (e.g. `getPublished('lead', 'views/all_leads')`),
533+
* matching how `getItem` addresses sub-resources.
534+
*/
535+
getPublished: async (type: string, name: string) => {
536+
const route = this.getRoute('metadata');
537+
const res = await this.fetch(`${this.baseUrl}${route}/${type}/${name}/published`);
538+
return this.unwrapResponse<any>(res);
539+
},
540+
541+
/**
542+
* ADR-0033: pending drafts — metadata authored (e.g. by an AI) but not
543+
* yet published, which the active-only item lists hide.
544+
*/
545+
listDrafts: async (opts?: { packageId?: string; type?: string }) => {
546+
const route = this.getRoute('metadata');
547+
const params = new URLSearchParams();
548+
if (opts?.packageId) params.set('packageId', opts.packageId);
549+
if (opts?.type) params.set('type', opts.type);
550+
const qs = params.toString();
551+
const res = await this.fetch(`${this.baseUrl}${route}/_drafts${qs ? `?${qs}` : ''}`);
552+
return this.unwrapResponse<any>(res);
553+
},
554+
555+
/**
556+
* ADR-0020 D3.3 FSM introspection: the legal next states for `field`
557+
* from state `from`, per the object's `state_machine` validation rule.
558+
* `next` is `null` when no FSM governs the field (or `from` is omitted),
559+
* `[]` for a declared dead-end state.
560+
*/
561+
getLegalNextStates: async (object: string, field: string, from?: string) => {
562+
const route = this.getRoute('metadata');
563+
const qs = from !== undefined ? `?from=${encodeURIComponent(from)}` : '';
564+
const res = await this.fetch(
565+
`${this.baseUrl}${route}/objects/${encodeURIComponent(object)}/state/${encodeURIComponent(field)}${qs}`,
566+
);
567+
return this.unwrapResponse<{ object: string; field: string; from: string | null; next: string[] | null }>(res);
526568
}
527569
};
528570

@@ -2359,6 +2401,45 @@ export class ObjectStackClient {
23592401
/**
23602402
* Enable or disable a flow
23612403
*/
2404+
/* [#3563 PR-5] The three descriptor/status routes that had no SDK
2405+
* expression — they back the Studio designer's pickers and badges. */
2406+
2407+
/**
2408+
* ADR-0018: registered action-node descriptors, optionally filtered by
2409+
* `paradigm` / `source` / `category`. Empty registry → `{ actions: [], total: 0 }`.
2410+
*/
2411+
listActions: async (opts?: { paradigm?: string; source?: string; category?: string }): Promise<{ actions: any[]; total: number }> => {
2412+
const route = this.getRoute('automation');
2413+
const params = new URLSearchParams();
2414+
if (opts?.paradigm) params.set('paradigm', opts.paradigm);
2415+
if (opts?.source) params.set('source', opts.source);
2416+
if (opts?.category) params.set('category', opts.category);
2417+
const qs = params.toString();
2418+
const res = await this.fetch(`${this.baseUrl}${route}/actions${qs ? `?${qs}` : ''}`);
2419+
return this.unwrapResponse(res);
2420+
},
2421+
2422+
/**
2423+
* ADR-0022: registered connector descriptors (populated by connector
2424+
* plugins), optionally filtered by `type`.
2425+
*/
2426+
listConnectors: async (opts?: { type?: string }): Promise<{ connectors: any[]; total: number }> => {
2427+
const route = this.getRoute('automation');
2428+
const qs = opts?.type ? `?type=${encodeURIComponent(opts.type)}` : '';
2429+
const res = await this.fetch(`${this.baseUrl}${route}/connectors${qs}`);
2430+
return this.unwrapResponse(res);
2431+
},
2432+
2433+
/**
2434+
* Runtime enable/bound state for every flow — engine state, not
2435+
* persisted metadata (backs the Studio's Automations status badges).
2436+
*/
2437+
getRuntimeStatus: async (): Promise<{ flows: Array<{ name: string; enabled: boolean; bound: boolean }>; total: number }> => {
2438+
const route = this.getRoute('automation');
2439+
const res = await this.fetch(`${this.baseUrl}${route}/_status`);
2440+
return this.unwrapResponse(res);
2441+
},
2442+
23622443
toggle: async (name: string, enabled: boolean): Promise<{ name: string; enabled: boolean }> => {
23632444
const route = this.getRoute('automation');
23642445
const res = await this.fetch(`${this.baseUrl}${route}/${name}/toggle`, {
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The final six #3563 gap closures (PR-5): meta published/drafts/FSM and the
5+
* automation descriptor/status routes. With these, every should-be-SDK
6+
* dispatcher route has an SDK expression and the ledger's gap ratchet is 0.
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.meta (#3563 PR-5)', () => {
28+
it('getPublished passes compound names through unencoded', async () => {
29+
const { client, fetchMock } = createMockClient({ success: true, data: { name: 'all_leads' } });
30+
await client.meta.getPublished('lead', 'views/all_leads');
31+
expect(String(fetchMock.mock.calls[0][0])).toBe(
32+
'http://localhost:3000/api/v1/meta/lead/views/all_leads/published',
33+
);
34+
});
35+
36+
it('listDrafts builds the packageId/type query and omits empties', async () => {
37+
const { client, fetchMock } = createMockClient({ success: true, data: { drafts: [] } });
38+
await client.meta.listDrafts({ packageId: 'com.acme.crm', type: 'object' });
39+
expect(String(fetchMock.mock.calls[0][0])).toBe(
40+
'http://localhost:3000/api/v1/meta/_drafts?packageId=com.acme.crm&type=object',
41+
);
42+
await client.meta.listDrafts();
43+
expect(String(fetchMock.mock.calls[1][0])).toBe('http://localhost:3000/api/v1/meta/_drafts');
44+
});
45+
46+
it('getLegalNextStates hits the FSM route and forwards from=', async () => {
47+
const { client, fetchMock } = createMockClient({
48+
success: true,
49+
data: { object: 'crm_lead', field: 'status', from: 'new', next: ['contacted'] },
50+
});
51+
const out = await client.meta.getLegalNextStates('crm_lead', 'status', 'new');
52+
expect(String(fetchMock.mock.calls[0][0])).toBe(
53+
'http://localhost:3000/api/v1/meta/objects/crm_lead/state/status?from=new',
54+
);
55+
expect(out.next).toEqual(['contacted']);
56+
57+
// Omitted `from` → no query; the server answers next: null.
58+
await client.meta.getLegalNextStates('crm_lead', 'status');
59+
expect(String(fetchMock.mock.calls[1][0])).toBe(
60+
'http://localhost:3000/api/v1/meta/objects/crm_lead/state/status',
61+
);
62+
});
63+
});
64+
65+
describe('client.automation descriptors (#3563 PR-5)', () => {
66+
it('listActions forwards the paradigm/source/category filters', async () => {
67+
const { client, fetchMock } = createMockClient({ success: true, data: { actions: [], total: 0 } });
68+
await client.automation.listActions({ paradigm: 'flow', category: 'crud' });
69+
expect(String(fetchMock.mock.calls[0][0])).toBe(
70+
'http://localhost:3000/api/v1/automation/actions?paradigm=flow&category=crud',
71+
);
72+
await client.automation.listActions();
73+
expect(String(fetchMock.mock.calls[1][0])).toBe('http://localhost:3000/api/v1/automation/actions');
74+
});
75+
76+
it('listConnectors forwards the type filter', async () => {
77+
const { client, fetchMock } = createMockClient({ success: true, data: { connectors: [], total: 0 } });
78+
await client.automation.listConnectors({ type: 'rest' });
79+
expect(String(fetchMock.mock.calls[0][0])).toBe(
80+
'http://localhost:3000/api/v1/automation/connectors?type=rest',
81+
);
82+
});
83+
84+
it('getRuntimeStatus GETs the underscore-guarded _status route', async () => {
85+
const { client, fetchMock } = createMockClient({
86+
success: true,
87+
data: { flows: [{ name: 'f1', enabled: true, bound: true }], total: 1 },
88+
});
89+
const out = await client.automation.getRuntimeStatus();
90+
expect(String(fetchMock.mock.calls[0][0])).toBe('http://localhost:3000/api/v1/automation/_status');
91+
expect(out.flows[0].bound).toBe(true);
92+
});
93+
});

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,11 @@ describe('route ledger hygiene', () => {
8383
it('gap count only shrinks — update the ledger (and this number) when closing gaps', () => {
8484
// Ratchet, not aspiration: 27 audited gaps at #3563 PR-1; 24 after PR-2
8585
// (actions surface); 17 after PR-3 (keys / share-links / security);
86-
// 6 after PR-4 (packages lifecycle).
86+
// 6 after PR-4 (packages lifecycle); 0 after PR-5 (meta + automation
87+
// descriptors) — every should-be-SDK dispatcher route is now expressed.
8788
// Closing a gap = reclassify to `sdk` AND lower this bound. Raising it
8889
// demands an explicit, reviewed decision.
8990
const gaps = ROUTE_LEDGER.filter((e) => e.disposition === 'gap').length;
90-
expect(gaps).toBeLessThanOrEqual(6);
91+
expect(gaps).toBeLessThanOrEqual(0);
9192
});
9293
});

packages/runtime/src/route-ledger.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -145,9 +145,9 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [
145145
note: 'legacy verb-first shape; duplicates execute() against a different URL — candidates for consolidation' },
146146
{ route: 'GET /automation', domain: '/automation', disposition: 'sdk', client: 'automation.list' },
147147
{ route: 'POST /automation', domain: '/automation', disposition: 'sdk', client: 'automation.create' },
148-
{ route: 'GET /automation/actions', domain: '/automation', disposition: 'gap', note: 'ADR-0018 action descriptors — no client expression' },
149-
{ route: 'GET /automation/connectors', domain: '/automation', disposition: 'gap', note: 'ADR-0022 connector descriptors — no client expression' },
150-
{ route: 'GET /automation/_status', domain: '/automation', disposition: 'gap', note: 'per-flow runtime state — no client expression' },
148+
{ route: 'GET /automation/actions', domain: '/automation', disposition: 'sdk', client: 'automation.listActions' },
149+
{ route: 'GET /automation/connectors', domain: '/automation', disposition: 'sdk', client: 'automation.listConnectors' },
150+
{ route: 'GET /automation/_status', domain: '/automation', disposition: 'sdk', client: 'automation.getRuntimeStatus' },
151151
{ route: 'POST /automation/:name/trigger', domain: '/automation', disposition: 'sdk', client: 'automation.execute' },
152152
{ route: 'POST /automation/:name/toggle', domain: '/automation', disposition: 'sdk', client: 'automation.toggle' },
153153
{ route: 'POST /automation/:name/runs/:runId/resume', domain: '/automation', disposition: 'sdk', client: 'automation.resume' },
@@ -172,9 +172,9 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [
172172
{ route: 'GET /meta/:type', domain: '/meta', disposition: 'sdk', client: 'meta.getItems' },
173173
{ route: 'GET /meta/:type/:name', domain: '/meta', disposition: 'sdk', client: 'meta.getItem' },
174174
{ route: 'PUT /meta/:type/:name', domain: '/meta', disposition: 'sdk', client: 'meta.saveItem' },
175-
{ route: 'GET /meta/:type/:name/published', domain: '/meta', disposition: 'gap', note: 'ADR-0033 published version — no client expression' },
176-
{ route: 'GET /meta/_drafts', domain: '/meta', disposition: 'gap', note: 'ADR-0033 pending drafts — no client expression' },
177-
{ route: 'GET /meta/objects/:name/state/:field', domain: '/meta', disposition: 'gap', note: 'ADR-0020 legal next FSM states — no client expression' },
175+
{ route: 'GET /meta/:type/:name/published', domain: '/meta', disposition: 'sdk', client: 'meta.getPublished' },
176+
{ route: 'GET /meta/_drafts', domain: '/meta', disposition: 'sdk', client: 'meta.listDrafts' },
177+
{ route: 'GET /meta/objects/:name/state/:field', domain: '/meta', disposition: 'sdk', client: 'meta.getLegalNextStates' },
178178

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

0 commit comments

Comments
 (0)