diff --git a/.changeset/client-meta-automation-descriptors.md b/.changeset/client-meta-automation-descriptors.md new file mode 100644 index 0000000000..f56ede6cb7 --- /dev/null +++ b/.changeset/client-meta-automation-descriptors.md @@ -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. diff --git a/content/docs/api/client-sdk.mdx b/content/docs/api/client-sdk.mdx index aa8b947058..f362b20f98 100644 --- a/content/docs/api/client-sdk.mdx +++ b/content/docs/api/client-sdk.mdx @@ -109,7 +109,7 @@ 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 | @@ -117,7 +117,7 @@ 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** | ✅ | 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 | diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 66c9dba6cb..73a0628127 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -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(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(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); } }; @@ -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`, { diff --git a/packages/client/src/meta-automation-descriptors.test.ts b/packages/client/src/meta-automation-descriptors.test.ts new file mode 100644 index 0000000000..d771abe491 --- /dev/null +++ b/packages/client/src/meta-automation-descriptors.test.ts @@ -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); + }); +}); diff --git a/packages/runtime/src/route-ledger.conformance.test.ts b/packages/runtime/src/route-ledger.conformance.test.ts index a8e66ed7b4..2147bbfb97 100644 --- a/packages/runtime/src/route-ledger.conformance.test.ts +++ b/packages/runtime/src/route-ledger.conformance.test.ts @@ -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); }); }); diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index 1675335cba..d9673f91ff 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -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' }, @@ -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' },