Skip to content

Commit d374bdb

Browse files
committed
feat(client): close the 9 metadata-family REST gaps (#3587 batch 1/5)
Seven new meta methods, each URL-pinned in client.test.ts: getDiagnostics (spec-validation sweep), getReferences (reverse refs), getBookTree (ADR-0046 §6), getAudit (ADR-0010 §3.6 protection trail), publishItem / rollbackItem / diffItem (ADR-0033 per-item lifecycle). The two compound-name routes (GET|PUT /meta/:type/:section/:name) were already expressible — getItem/saveItem pass slashes through unencoded; the audit note claiming an encoding barrier described deleteItem, not them. Flipped to sdk with pinning tests locking the pass-through instead of adding methods. Ledger: 9 metadata rows gap→sdk; ratchet 43→34. client-sdk.mdx meta section updated (11→18 methods + examples). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LX9ut3MK3KykE11S9bJmv5
1 parent 16adb3c commit d374bdb

6 files changed

Lines changed: 217 additions & 25 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/client": minor
3+
"@objectstack/rest": patch
4+
---
5+
6+
feat(client): close the 9 metadata-family REST gaps the #3587 ledger carried (#3587)
7+
8+
New `meta` surface: `getDiagnostics` (spec-validation sweep), `getReferences`
9+
(reverse references), `getBookTree` (ADR-0046 §6 spine resolution), `getAudit`
10+
(ADR-0010 §3.6 protection trail), `publishItem` / `rollbackItem` / `diffItem`
11+
(ADR-0033 per-item draft lifecycle). The two compound-name routes
12+
(`GET|PUT /meta/:type/:section/:name`) turned out to be already expressible —
13+
`getItem`/`saveItem` pass slashes through unencoded — so they are flipped to
14+
`sdk` with URL-pinning tests instead of new methods (the audit note claiming
15+
an encoding barrier was wrong; only `deleteItem` encodes). REST route-ledger
16+
ratchet: 43 → 34.

content/docs/api/client-sdk.mdx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ 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** || 11 | Metadata read/write, published versions & drafts (ADR-0033), FSM introspection (ADR-0020) |
112+
| **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) |
113113
| **data** || 10 | CRUD & query operations |
114114
| **auth** || 5 | Authentication & user management |
115115
| **packages** || 17 | Package lifecycle: install/enable, drafts (ADR-0033), commits & rollback (ADR-0067), export/duplicate (ADR-0070) |
@@ -163,6 +163,20 @@ if (cached.notModified) {
163163

164164
// Get auto-generated view
165165
const listView = await client.meta.getView('account', 'list');
166+
167+
// Compound names (sub-resources) pass through unencoded
168+
const view = await client.meta.getItem('object', 'views/all_leads');
169+
170+
// Per-item draft lifecycle (ADR-0033)
171+
await client.meta.publishItem('object', 'account', { message: 'go live' });
172+
await client.meta.rollbackItem('object', 'account', 3);
173+
const diff = await client.meta.diffItem('object', 'account', { from: 2, to: 5 });
174+
175+
// Introspection & governance
176+
const bad = await client.meta.getDiagnostics({ severity: 'error' });
177+
const refs = await client.meta.getReferences('object', 'account');
178+
const trail = await client.meta.getAudit('object', 'account', { limit: 20 });
179+
const tree = await client.meta.getBookTree('handbook');
166180
```
167181

168182
### `client.data` — CRUD & Batch

packages/client/src/client.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,83 @@ describe('ObjectStackClient', () => {
119119
'http://localhost:3000/api/v1/ui/view/customer/form',
120120
);
121121
});
122+
123+
it('meta.getDiagnostics pins GET /meta/diagnostics with its query params', async () => {
124+
const { client, fetchMock } = createMockClient({ success: true, data: { items: [] } });
125+
await client.meta.getDiagnostics();
126+
expect(String(fetchMock.mock.calls[0][0])).toBe(
127+
'http://localhost:3000/api/v1/meta/diagnostics',
128+
);
129+
await client.meta.getDiagnostics({ type: 'object', severity: 'warning', packageId: 'com.example.crm' });
130+
expect(String(fetchMock.mock.calls[1][0])).toBe(
131+
'http://localhost:3000/api/v1/meta/diagnostics?type=object&severity=warning&package=com.example.crm',
132+
);
133+
});
134+
135+
it('meta.getReferences pins GET /meta/:type/:name/references', async () => {
136+
const { client, fetchMock } = createMockClient({ success: true, data: { references: [] } });
137+
await client.meta.getReferences('object', 'customer');
138+
expect(String(fetchMock.mock.calls[0][0])).toBe(
139+
'http://localhost:3000/api/v1/meta/object/customer/references',
140+
);
141+
});
142+
143+
it('meta.getBookTree pins GET /meta/book/:name/tree', async () => {
144+
const { client, fetchMock } = createMockClient({ success: true, data: { tree: [] } });
145+
await client.meta.getBookTree('handbook', { packageId: 'com.example.docs' });
146+
expect(String(fetchMock.mock.calls[0][0])).toBe(
147+
'http://localhost:3000/api/v1/meta/book/handbook/tree?package=com.example.docs',
148+
);
149+
});
150+
151+
it('meta.getAudit pins GET /meta/:type/:name/audit', async () => {
152+
const { client, fetchMock } = createMockClient({ success: true, data: { events: [] } });
153+
await client.meta.getAudit('object', 'customer', { limit: 20 });
154+
expect(String(fetchMock.mock.calls[0][0])).toBe(
155+
'http://localhost:3000/api/v1/meta/object/customer/audit?limit=20',
156+
);
157+
});
158+
159+
it('meta.publishItem pins POST /meta/:type/:name/publish and passes message', async () => {
160+
const { client, fetchMock } = createMockClient({ success: true, data: { published: true } });
161+
await client.meta.publishItem('object', 'customer', { message: 'go live' });
162+
const [url, init] = fetchMock.mock.calls[0];
163+
expect(String(url)).toBe('http://localhost:3000/api/v1/meta/object/customer/publish');
164+
expect(init.method).toBe('POST');
165+
expect(JSON.parse(init.body)).toEqual({ message: 'go live' });
166+
});
167+
168+
it('meta.rollbackItem pins POST /meta/:type/:name/rollback with toVersion', async () => {
169+
const { client, fetchMock } = createMockClient({ success: true, data: { restored: true } });
170+
await client.meta.rollbackItem('object', 'customer', 3);
171+
const [url, init] = fetchMock.mock.calls[0];
172+
expect(String(url)).toBe('http://localhost:3000/api/v1/meta/object/customer/rollback');
173+
expect(init.method).toBe('POST');
174+
expect(JSON.parse(init.body)).toEqual({ toVersion: 3 });
175+
});
176+
177+
it('meta.diffItem pins GET /meta/:type/:name/diff with from/to', async () => {
178+
const { client, fetchMock } = createMockClient({ success: true, data: { changes: [] } });
179+
await client.meta.diffItem('object', 'customer', { from: 2, to: 5 });
180+
expect(String(fetchMock.mock.calls[0][0])).toBe(
181+
'http://localhost:3000/api/v1/meta/object/customer/diff?from=2&to=5',
182+
);
183+
});
184+
185+
it('meta.getItem/saveItem pass compound names through unencoded (reaches /meta/:type/:section/:name)', async () => {
186+
const { client, fetchMock } = createMockClient({ success: true, data: { name: 'views/all_leads' } });
187+
await client.meta.getItem('object', 'views/all_leads');
188+
// The slash must survive: %2F would collapse the request onto the
189+
// 3-segment /meta/:type/:name route and miss the compound handler.
190+
expect(String(fetchMock.mock.calls[0][0])).toBe(
191+
'http://localhost:3000/api/v1/meta/object/views/all_leads',
192+
);
193+
await client.meta.saveItem('object', 'views/all_leads', { label: 'All leads' });
194+
expect(String(fetchMock.mock.calls[1][0])).toBe(
195+
'http://localhost:3000/api/v1/meta/object/views/all_leads',
196+
);
197+
expect(fetchMock.mock.calls[1][1].method).toBe('PUT');
198+
});
122199
});
123200

124201
describe('Approvals namespace (ADR-0019)', () => {

packages/client/src/index.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,96 @@ export class ObjectStackClient {
546546
`${this.baseUrl}${route}/objects/${encodeURIComponent(object)}/state/${encodeURIComponent(field)}${qs}`,
547547
);
548548
return this.unwrapResponse<{ object: string; field: string; from: string | null; next: string[] | null }>(res);
549+
},
550+
551+
/**
552+
* Cross-type spec-validation sweep: every metadata entry that fails its
553+
* registered Zod schema. Powers governance dashboards and doctor-style
554+
* checks. 501s on kernels without `getMetaDiagnostics`.
555+
*/
556+
getDiagnostics: async (opts?: { type?: string; severity?: 'error' | 'warning'; packageId?: string }) => {
557+
const route = this.getRoute('metadata');
558+
const params = new URLSearchParams();
559+
if (opts?.type) params.set('type', opts.type);
560+
if (opts?.severity) params.set('severity', opts.severity);
561+
if (opts?.packageId) params.set('package', opts.packageId);
562+
const qs = params.toString();
563+
const res = await this.fetch(`${this.baseUrl}${route}/diagnostics${qs ? `?${qs}` : ''}`);
564+
return this.unwrapResponse<any>(res);
565+
},
566+
567+
/**
568+
* Reverse references: metadata items that reference `type`/`name`.
569+
* `{ references: [] }` on kernels without reference tracking.
570+
*/
571+
getReferences: async (type: string, name: string) => {
572+
const route = this.getRoute('metadata');
573+
const res = await this.fetch(`${this.baseUrl}${route}/${type}/${name}/references`);
574+
return this.unwrapResponse<any>(res);
575+
},
576+
577+
/**
578+
* ADR-0046 §6: resolve a book spine against the docs that exist now.
579+
* An unknown name is treated as a package id (implicit per-package book).
580+
*/
581+
getBookTree: async (name: string, opts?: { packageId?: string }) => {
582+
const route = this.getRoute('metadata');
583+
const qs = opts?.packageId ? `?package=${encodeURIComponent(opts.packageId)}` : '';
584+
const res = await this.fetch(`${this.baseUrl}${route}/book/${encodeURIComponent(name)}/tree${qs}`);
585+
return this.unwrapResponse<any>(res);
586+
},
587+
588+
/**
589+
* ADR-0010 §3.6 protection-audit trail for a metadata item: recent
590+
* save/publish/rollback/delete/reset attempts, allowed and denied.
591+
* `{ events: [] }` where the audit table is not provisioned.
592+
*/
593+
getAudit: async (type: string, name: string, opts?: { limit?: number }) => {
594+
const route = this.getRoute('metadata');
595+
const qs = opts?.limit !== undefined ? `?limit=${opts.limit}` : '';
596+
const res = await this.fetch(`${this.baseUrl}${route}/${type}/${name}/audit${qs}`);
597+
return this.unwrapResponse<any>(res);
598+
},
599+
600+
/**
601+
* ADR-0033: promote a single item's pending draft overlay to live —
602+
* the per-item flow beside `packages.publishDrafts`' package-scoped one.
603+
* 404 [no_draft] when there is nothing to publish. Compound names pass
604+
* through unencoded, like `getItem`.
605+
*/
606+
publishItem: async (type: string, name: string, opts?: { message?: string }) => {
607+
const route = this.getRoute('metadata');
608+
const res = await this.fetch(`${this.baseUrl}${route}/${type}/${name}/publish`, {
609+
method: 'POST',
610+
body: JSON.stringify(opts?.message ? { message: opts.message } : {}),
611+
});
612+
return this.unwrapResponse<any>(res);
613+
},
614+
615+
/**
616+
* Restore the body at history version `toVersion` as the new live row.
617+
*/
618+
rollbackItem: async (type: string, name: string, toVersion: number, opts?: { message?: string }) => {
619+
const route = this.getRoute('metadata');
620+
const res = await this.fetch(`${this.baseUrl}${route}/${type}/${name}/rollback`, {
621+
method: 'POST',
622+
body: JSON.stringify({ toVersion, ...(opts?.message ? { message: opts.message } : {}) }),
623+
});
624+
return this.unwrapResponse<any>(res);
625+
},
626+
627+
/**
628+
* Structural diff between two history versions (`from`/`to`); omit both
629+
* for previous-vs-current.
630+
*/
631+
diffItem: async (type: string, name: string, opts?: { from?: number; to?: number }) => {
632+
const route = this.getRoute('metadata');
633+
const params = new URLSearchParams();
634+
if (opts?.from !== undefined) params.set('from', String(opts.from));
635+
if (opts?.to !== undefined) params.set('to', String(opts.to));
636+
const qs = params.toString();
637+
const res = await this.fetch(`${this.baseUrl}${route}/${type}/${name}/diff${qs ? `?${qs}` : ''}`);
638+
return this.unwrapResponse<any>(res);
549639
}
550640
};
551641

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

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -144,12 +144,13 @@ describe('REST route ledger hygiene', () => {
144144
});
145145

146146
it('gap count only shrinks — update the ledger (and this number) when closing gaps', () => {
147-
// Ratchet, not aspiration: 43 audited gaps at #3587 PR-1 (metadata 9,
148-
// data-actions 2, search 1, email 1, analytics 1, security-explain 2,
149-
// record-shares 3, sharing-rules 5, reports 8, approvals 6,
150-
// external-datasource 5). Closing a gap = reclassify to `sdk` AND lower
151-
// this bound. Raising it demands an explicit, reviewed decision.
147+
// Ratchet, not aspiration: 43 audited gaps at #3587 PR-1; 34 after the
148+
// metadata batch closed its 9 (data-actions 2, search 1, email 1,
149+
// analytics 1, security-explain 2, record-shares 3, sharing-rules 5,
150+
// reports 8, approvals 6, external-datasource 5 remain). Closing a gap =
151+
// reclassify to `sdk` AND lower this bound. Raising it demands an
152+
// explicit, reviewed decision.
152153
const gaps = REST_ROUTE_LEDGER.filter((e) => e.disposition === 'gap').length;
153-
expect(gaps).toBeLessThanOrEqual(43);
154+
expect(gaps).toBeLessThanOrEqual(34);
154155
});
155156
});

packages/rest/src/rest-route-ledger.ts

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -80,32 +80,26 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [
8080

8181
// ── metadata ──────────────────────────────────────────────────────────────
8282
{ route: 'GET /api/v1/meta', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getTypes' },
83-
{ route: 'GET /api/v1/meta/diagnostics', family: 'metadata', source: 'route-manager', disposition: 'gap',
84-
note: 'spec-validation diagnostics listing — Studio-grade admin data with no SDK expression' },
83+
{ route: 'GET /api/v1/meta/diagnostics', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getDiagnostics' },
8584
{ route: 'GET /api/v1/meta/_drafts', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.listDrafts' },
8685
{ route: 'GET /api/v1/meta/:type', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getItems' },
87-
{ route: 'GET /api/v1/meta/:type/:name/references', family: 'metadata', source: 'route-manager', disposition: 'gap',
88-
note: 'reverse-reference listing with no SDK expression' },
89-
{ route: 'GET /api/v1/meta/book/:name/tree', family: 'metadata', source: 'route-manager', disposition: 'gap',
90-
note: 'ADR-0046 §6 book-spine resolution with no SDK expression' },
86+
{ route: 'GET /api/v1/meta/:type/:name/references', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getReferences' },
87+
{ route: 'GET /api/v1/meta/book/:name/tree', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getBookTree' },
9188
{ route: 'GET /api/v1/meta/:type/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getItem' },
9289
{ route: 'PUT /api/v1/meta/:type/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.saveItem' },
9390
{ route: 'DELETE /api/v1/meta/:type/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.deleteItem',
9491
note: 'REST-only: the dispatcher /meta branch has no DELETE handling — it falls into the read path' },
9592
{ route: 'GET /api/v1/meta/:type/:name/history', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getHistory',
9693
note: 'REST-only: the dispatcher /meta branch swallows /history as a compound name and 404s' },
97-
{ route: 'GET /api/v1/meta/:type/:name/audit', family: 'metadata', source: 'route-manager', disposition: 'gap',
98-
note: 'protection-audit events with no SDK expression' },
99-
{ route: 'POST /api/v1/meta/:type/:name/publish', family: 'metadata', source: 'route-manager', disposition: 'gap',
100-
note: 'per-item draft→active publish (ADR-0033) with no SDK expression; packages.publishDrafts covers only the package-scoped flow' },
101-
{ route: 'POST /api/v1/meta/:type/:name/rollback', family: 'metadata', source: 'route-manager', disposition: 'gap',
102-
note: 'restore-at-history-version with no SDK expression' },
103-
{ route: 'GET /api/v1/meta/:type/:name/diff', family: 'metadata', source: 'route-manager', disposition: 'gap',
104-
note: 'metadata version diff (?from/?to) with no SDK expression' },
105-
{ route: 'GET /api/v1/meta/:type/:section/:name', family: 'metadata', source: 'route-manager', disposition: 'gap',
106-
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' },
107-
{ route: 'PUT /api/v1/meta/:type/:section/:name', family: 'metadata', source: 'route-manager', disposition: 'gap',
108-
note: 'compound-name save; same encoding barrier as the compound read' },
94+
{ route: 'GET /api/v1/meta/:type/:name/audit', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getAudit' },
95+
{ route: 'POST /api/v1/meta/:type/:name/publish', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.publishItem',
96+
note: 'per-item ADR-0033 publish; packages.publishDrafts remains the package-scoped flow' },
97+
{ route: 'POST /api/v1/meta/:type/:name/rollback', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.rollbackItem' },
98+
{ route: 'GET /api/v1/meta/:type/:name/diff', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.diffItem' },
99+
{ route: 'GET /api/v1/meta/:type/:section/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getItem',
100+
note: 'compound names pass through getItem unencoded (URL-pinned in client.test.ts); only deleteItem encodes' },
101+
{ route: 'PUT /api/v1/meta/:type/:section/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.saveItem',
102+
note: 'compound names pass through saveItem unencoded (URL-pinned in client.test.ts)' },
109103

110104
// ── ui ────────────────────────────────────────────────────────────────────
111105
{ route: 'GET /api/v1/ui/view/:object/:type', family: 'ui', source: 'route-manager', disposition: 'sdk', client: 'meta.getView',

0 commit comments

Comments
 (0)