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
16 changes: 16 additions & 0 deletions .changeset/close-the-nine-metadata-rest-gaps.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 15 additions & 1 deletion content/docs/api/client-sdk.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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
Expand Down
77 changes: 77 additions & 0 deletions packages/client/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down
90 changes: 90 additions & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>(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<any>(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<any>(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<any>(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<any>(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<any>(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<any>(res);
}
};

Expand Down
13 changes: 7 additions & 6 deletions packages/rest/src/rest-route-ledger.conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
30 changes: 12 additions & 18 deletions packages/rest/src/rest-route-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading