diff --git a/.changeset/close-the-nine-metadata-rest-gaps.md b/.changeset/close-the-nine-metadata-rest-gaps.md new file mode 100644 index 0000000000..4306a54d54 --- /dev/null +++ b/.changeset/close-the-nine-metadata-rest-gaps.md @@ -0,0 +1,16 @@ +--- +"@objectstack/client": minor +"@objectstack/rest": patch +--- + +feat(client): close the 9 metadata-family REST gaps the #3587 ledger carried (#3587) + +New `meta` surface: `getDiagnostics` (spec-validation sweep), `getReferences` +(reverse references), `getBookTree` (ADR-0046 §6 spine resolution), `getAudit` +(ADR-0010 §3.6 protection trail), `publishItem` / `rollbackItem` / `diffItem` +(ADR-0033 per-item draft lifecycle). The two compound-name routes +(`GET|PUT /meta/:type/:section/:name`) turned out to be already expressible — +`getItem`/`saveItem` pass slashes through unencoded — so they are flipped to +`sdk` with URL-pinning tests instead of new methods (the audit note claiming +an encoding barrier was wrong; only `deleteItem` encodes). REST route-ledger +ratchet: 43 → 34. diff --git a/content/docs/api/client-sdk.mdx b/content/docs/api/client-sdk.mdx index 63b79681d5..d074617915 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** | ✅ | 11 | Metadata read/write, published versions & drafts (ADR-0033), FSM introspection (ADR-0020) | +| **meta** | ✅ | 18 | Metadata read/write, published versions & drafts (ADR-0033), per-item publish/rollback/diff, FSM introspection (ADR-0020), diagnostics, references, audit trail, book trees (ADR-0046) | | **data** | ✅ | 10 | CRUD & query operations | | **auth** | ✅ | 5 | Authentication & user management | | **packages** | ✅ | 17 | Package lifecycle: install/enable, drafts (ADR-0033), commits & rollback (ADR-0067), export/duplicate (ADR-0070) | @@ -163,6 +163,20 @@ if (cached.notModified) { // Get auto-generated view const listView = await client.meta.getView('account', 'list'); + +// Compound names (sub-resources) pass through unencoded +const view = await client.meta.getItem('object', 'views/all_leads'); + +// Per-item draft lifecycle (ADR-0033) +await client.meta.publishItem('object', 'account', { message: 'go live' }); +await client.meta.rollbackItem('object', 'account', 3); +const diff = await client.meta.diffItem('object', 'account', { from: 2, to: 5 }); + +// Introspection & governance +const bad = await client.meta.getDiagnostics({ severity: 'error' }); +const refs = await client.meta.getReferences('object', 'account'); +const trail = await client.meta.getAudit('object', 'account', { limit: 20 }); +const tree = await client.meta.getBookTree('handbook'); ``` ### `client.data` — CRUD & Batch diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index 0841cc7806..1319ebb610 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -119,6 +119,83 @@ describe('ObjectStackClient', () => { 'http://localhost:3000/api/v1/ui/view/customer/form', ); }); + + it('meta.getDiagnostics pins GET /meta/diagnostics with its query params', async () => { + const { client, fetchMock } = createMockClient({ success: true, data: { items: [] } }); + await client.meta.getDiagnostics(); + expect(String(fetchMock.mock.calls[0][0])).toBe( + 'http://localhost:3000/api/v1/meta/diagnostics', + ); + await client.meta.getDiagnostics({ type: 'object', severity: 'warning', packageId: 'com.example.crm' }); + expect(String(fetchMock.mock.calls[1][0])).toBe( + 'http://localhost:3000/api/v1/meta/diagnostics?type=object&severity=warning&package=com.example.crm', + ); + }); + + it('meta.getReferences pins GET /meta/:type/:name/references', async () => { + const { client, fetchMock } = createMockClient({ success: true, data: { references: [] } }); + await client.meta.getReferences('object', 'customer'); + expect(String(fetchMock.mock.calls[0][0])).toBe( + 'http://localhost:3000/api/v1/meta/object/customer/references', + ); + }); + + it('meta.getBookTree pins GET /meta/book/:name/tree', async () => { + const { client, fetchMock } = createMockClient({ success: true, data: { tree: [] } }); + await client.meta.getBookTree('handbook', { packageId: 'com.example.docs' }); + expect(String(fetchMock.mock.calls[0][0])).toBe( + 'http://localhost:3000/api/v1/meta/book/handbook/tree?package=com.example.docs', + ); + }); + + it('meta.getAudit pins GET /meta/:type/:name/audit', async () => { + const { client, fetchMock } = createMockClient({ success: true, data: { events: [] } }); + await client.meta.getAudit('object', 'customer', { limit: 20 }); + expect(String(fetchMock.mock.calls[0][0])).toBe( + 'http://localhost:3000/api/v1/meta/object/customer/audit?limit=20', + ); + }); + + it('meta.publishItem pins POST /meta/:type/:name/publish and passes message', async () => { + const { client, fetchMock } = createMockClient({ success: true, data: { published: true } }); + await client.meta.publishItem('object', 'customer', { message: 'go live' }); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toBe('http://localhost:3000/api/v1/meta/object/customer/publish'); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body)).toEqual({ message: 'go live' }); + }); + + it('meta.rollbackItem pins POST /meta/:type/:name/rollback with toVersion', async () => { + const { client, fetchMock } = createMockClient({ success: true, data: { restored: true } }); + await client.meta.rollbackItem('object', 'customer', 3); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toBe('http://localhost:3000/api/v1/meta/object/customer/rollback'); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body)).toEqual({ toVersion: 3 }); + }); + + it('meta.diffItem pins GET /meta/:type/:name/diff with from/to', async () => { + const { client, fetchMock } = createMockClient({ success: true, data: { changes: [] } }); + await client.meta.diffItem('object', 'customer', { from: 2, to: 5 }); + expect(String(fetchMock.mock.calls[0][0])).toBe( + 'http://localhost:3000/api/v1/meta/object/customer/diff?from=2&to=5', + ); + }); + + it('meta.getItem/saveItem pass compound names through unencoded (reaches /meta/:type/:section/:name)', async () => { + const { client, fetchMock } = createMockClient({ success: true, data: { name: 'views/all_leads' } }); + await client.meta.getItem('object', 'views/all_leads'); + // The slash must survive: %2F would collapse the request onto the + // 3-segment /meta/:type/:name route and miss the compound handler. + expect(String(fetchMock.mock.calls[0][0])).toBe( + 'http://localhost:3000/api/v1/meta/object/views/all_leads', + ); + await client.meta.saveItem('object', 'views/all_leads', { label: 'All leads' }); + expect(String(fetchMock.mock.calls[1][0])).toBe( + 'http://localhost:3000/api/v1/meta/object/views/all_leads', + ); + expect(fetchMock.mock.calls[1][1].method).toBe('PUT'); + }); }); describe('Approvals namespace (ADR-0019)', () => { diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index b44747e624..f37f55b453 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -546,6 +546,96 @@ export class ObjectStackClient { `${this.baseUrl}${route}/objects/${encodeURIComponent(object)}/state/${encodeURIComponent(field)}${qs}`, ); return this.unwrapResponse<{ object: string; field: string; from: string | null; next: string[] | null }>(res); + }, + + /** + * Cross-type spec-validation sweep: every metadata entry that fails its + * registered Zod schema. Powers governance dashboards and doctor-style + * checks. 501s on kernels without `getMetaDiagnostics`. + */ + getDiagnostics: async (opts?: { type?: string; severity?: 'error' | 'warning'; packageId?: string }) => { + const route = this.getRoute('metadata'); + const params = new URLSearchParams(); + if (opts?.type) params.set('type', opts.type); + if (opts?.severity) params.set('severity', opts.severity); + if (opts?.packageId) params.set('package', opts.packageId); + const qs = params.toString(); + const res = await this.fetch(`${this.baseUrl}${route}/diagnostics${qs ? `?${qs}` : ''}`); + return this.unwrapResponse(res); + }, + + /** + * Reverse references: metadata items that reference `type`/`name`. + * `{ references: [] }` on kernels without reference tracking. + */ + getReferences: async (type: string, name: string) => { + const route = this.getRoute('metadata'); + const res = await this.fetch(`${this.baseUrl}${route}/${type}/${name}/references`); + return this.unwrapResponse(res); + }, + + /** + * ADR-0046 §6: resolve a book spine against the docs that exist now. + * An unknown name is treated as a package id (implicit per-package book). + */ + getBookTree: async (name: string, opts?: { packageId?: string }) => { + const route = this.getRoute('metadata'); + const qs = opts?.packageId ? `?package=${encodeURIComponent(opts.packageId)}` : ''; + const res = await this.fetch(`${this.baseUrl}${route}/book/${encodeURIComponent(name)}/tree${qs}`); + return this.unwrapResponse(res); + }, + + /** + * ADR-0010 §3.6 protection-audit trail for a metadata item: recent + * save/publish/rollback/delete/reset attempts, allowed and denied. + * `{ events: [] }` where the audit table is not provisioned. + */ + getAudit: async (type: string, name: string, opts?: { limit?: number }) => { + const route = this.getRoute('metadata'); + const qs = opts?.limit !== undefined ? `?limit=${opts.limit}` : ''; + const res = await this.fetch(`${this.baseUrl}${route}/${type}/${name}/audit${qs}`); + return this.unwrapResponse(res); + }, + + /** + * ADR-0033: promote a single item's pending draft overlay to live — + * the per-item flow beside `packages.publishDrafts`' package-scoped one. + * 404 [no_draft] when there is nothing to publish. Compound names pass + * through unencoded, like `getItem`. + */ + publishItem: async (type: string, name: string, opts?: { message?: string }) => { + const route = this.getRoute('metadata'); + const res = await this.fetch(`${this.baseUrl}${route}/${type}/${name}/publish`, { + method: 'POST', + body: JSON.stringify(opts?.message ? { message: opts.message } : {}), + }); + return this.unwrapResponse(res); + }, + + /** + * Restore the body at history version `toVersion` as the new live row. + */ + rollbackItem: async (type: string, name: string, toVersion: number, opts?: { message?: string }) => { + const route = this.getRoute('metadata'); + const res = await this.fetch(`${this.baseUrl}${route}/${type}/${name}/rollback`, { + method: 'POST', + body: JSON.stringify({ toVersion, ...(opts?.message ? { message: opts.message } : {}) }), + }); + return this.unwrapResponse(res); + }, + + /** + * Structural diff between two history versions (`from`/`to`); omit both + * for previous-vs-current. + */ + diffItem: async (type: string, name: string, opts?: { from?: number; to?: number }) => { + const route = this.getRoute('metadata'); + const params = new URLSearchParams(); + if (opts?.from !== undefined) params.set('from', String(opts.from)); + if (opts?.to !== undefined) params.set('to', String(opts.to)); + const qs = params.toString(); + const res = await this.fetch(`${this.baseUrl}${route}/${type}/${name}/diff${qs ? `?${qs}` : ''}`); + return this.unwrapResponse(res); } }; diff --git a/packages/rest/src/rest-route-ledger.conformance.test.ts b/packages/rest/src/rest-route-ledger.conformance.test.ts index e0ada11a96..b4c3be9bff 100644 --- a/packages/rest/src/rest-route-ledger.conformance.test.ts +++ b/packages/rest/src/rest-route-ledger.conformance.test.ts @@ -144,12 +144,13 @@ describe('REST route ledger hygiene', () => { }); it('gap count only shrinks — update the ledger (and this number) when closing gaps', () => { - // Ratchet, not aspiration: 43 audited gaps at #3587 PR-1 (metadata 9, - // data-actions 2, search 1, email 1, analytics 1, security-explain 2, - // record-shares 3, sharing-rules 5, reports 8, approvals 6, - // external-datasource 5). Closing a gap = reclassify to `sdk` AND lower - // this bound. Raising it demands an explicit, reviewed decision. + // Ratchet, not aspiration: 43 audited gaps at #3587 PR-1; 34 after the + // metadata batch closed its 9 (data-actions 2, search 1, email 1, + // analytics 1, security-explain 2, record-shares 3, sharing-rules 5, + // reports 8, approvals 6, external-datasource 5 remain). Closing a gap = + // reclassify to `sdk` AND lower this bound. Raising it demands an + // explicit, reviewed decision. const gaps = REST_ROUTE_LEDGER.filter((e) => e.disposition === 'gap').length; - expect(gaps).toBeLessThanOrEqual(43); + expect(gaps).toBeLessThanOrEqual(34); }); }); diff --git a/packages/rest/src/rest-route-ledger.ts b/packages/rest/src/rest-route-ledger.ts index fc67c85ca9..894632a9ca 100644 --- a/packages/rest/src/rest-route-ledger.ts +++ b/packages/rest/src/rest-route-ledger.ts @@ -80,32 +80,26 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ // ── metadata ────────────────────────────────────────────────────────────── { route: 'GET /api/v1/meta', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getTypes' }, - { route: 'GET /api/v1/meta/diagnostics', family: 'metadata', source: 'route-manager', disposition: 'gap', - note: 'spec-validation diagnostics listing — Studio-grade admin data with no SDK expression' }, + { route: 'GET /api/v1/meta/diagnostics', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getDiagnostics' }, { route: 'GET /api/v1/meta/_drafts', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.listDrafts' }, { route: 'GET /api/v1/meta/:type', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getItems' }, - { route: 'GET /api/v1/meta/:type/:name/references', family: 'metadata', source: 'route-manager', disposition: 'gap', - note: 'reverse-reference listing with no SDK expression' }, - { route: 'GET /api/v1/meta/book/:name/tree', family: 'metadata', source: 'route-manager', disposition: 'gap', - note: 'ADR-0046 §6 book-spine resolution with no SDK expression' }, + { route: 'GET /api/v1/meta/:type/:name/references', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getReferences' }, + { route: 'GET /api/v1/meta/book/:name/tree', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getBookTree' }, { route: 'GET /api/v1/meta/:type/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getItem' }, { route: 'PUT /api/v1/meta/:type/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.saveItem' }, { route: 'DELETE /api/v1/meta/:type/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.deleteItem', note: 'REST-only: the dispatcher /meta branch has no DELETE handling — it falls into the read path' }, { route: 'GET /api/v1/meta/:type/:name/history', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getHistory', note: 'REST-only: the dispatcher /meta branch swallows /history as a compound name and 404s' }, - { route: 'GET /api/v1/meta/:type/:name/audit', family: 'metadata', source: 'route-manager', disposition: 'gap', - note: 'protection-audit events with no SDK expression' }, - { route: 'POST /api/v1/meta/:type/:name/publish', family: 'metadata', source: 'route-manager', disposition: 'gap', - note: 'per-item draft→active publish (ADR-0033) with no SDK expression; packages.publishDrafts covers only the package-scoped flow' }, - { route: 'POST /api/v1/meta/:type/:name/rollback', family: 'metadata', source: 'route-manager', disposition: 'gap', - note: 'restore-at-history-version with no SDK expression' }, - { route: 'GET /api/v1/meta/:type/:name/diff', family: 'metadata', source: 'route-manager', disposition: 'gap', - note: 'metadata version diff (?from/?to) with no SDK expression' }, - { route: 'GET /api/v1/meta/:type/:section/:name', family: 'metadata', source: 'route-manager', disposition: 'gap', - note: 'compound-name read; meta.getItem URL-encodes the slash so it cannot reach this shape — needs the unencoded pass-through meta.getPublished got in #3579' }, - { route: 'PUT /api/v1/meta/:type/:section/:name', family: 'metadata', source: 'route-manager', disposition: 'gap', - note: 'compound-name save; same encoding barrier as the compound read' }, + { route: 'GET /api/v1/meta/:type/:name/audit', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getAudit' }, + { route: 'POST /api/v1/meta/:type/:name/publish', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.publishItem', + note: 'per-item ADR-0033 publish; packages.publishDrafts remains the package-scoped flow' }, + { route: 'POST /api/v1/meta/:type/:name/rollback', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.rollbackItem' }, + { route: 'GET /api/v1/meta/:type/:name/diff', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.diffItem' }, + { route: 'GET /api/v1/meta/:type/:section/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getItem', + note: 'compound names pass through getItem unencoded (URL-pinned in client.test.ts); only deleteItem encodes' }, + { route: 'PUT /api/v1/meta/:type/:section/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.saveItem', + note: 'compound names pass through saveItem unencoded (URL-pinned in client.test.ts)' }, // ── ui ──────────────────────────────────────────────────────────────────── { route: 'GET /api/v1/ui/view/:object/:type', family: 'ui', source: 'route-manager', disposition: 'sdk', client: 'meta.getView',