Skip to content

Commit 6e62a93

Browse files
os-zhuangclaude
andauthored
feat(client): close the final 17 REST gaps — ratchet 17→0 (#3587 batches 4+5/5) (#3633)
Batch 4 — sharing-rules 5 + security-explain 2 + search 1: shares.rules.{list,save,get,delete,evaluate} administer tenant-wide sharing rules (M10.17; delete 204-safe, grants cascade). security.explain speaks the ADR-0090 D6 ExplainRequestSchema contract via the POST transport (the GET query form is the same contract — both ledger rows point at it). Top-level client.search covers global cross-object search (M10.5). Batch 5 — data-actions 2 + email 1 + analytics 1 + external-datasource 5: data.clone (enable.clone duplication, overrides nested), data.export (streaming CSV/JSON/XLSX; returns the raw Response — a file stream, not a JSON envelope, and the ledger row says so), email.send (IEmailService; branch on returned status), analytics.queryDataset (ADR-0021 REST dataset-query dialect, distinct from query's dispatcher dialect), and datasources.external.{listTables,draft,import,refreshCatalog,validate} (ADR-0015 Addendum federation admin, 503-degrading direct-mounts). Every REST route is now either SDK-expressed or carries a reviewed non-sdk disposition; the ratchet rests at ZERO and stays there. Docs: client-sdk.mdx surface table updated (data 12, analytics 4, security 4, shares 8 + email/datasources/search rows). Claude-Session: https://claude.ai/code/session_01LX9ut3MK3KykE11S9bJmv5 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9e2caf3 commit 6e62a93

7 files changed

Lines changed: 432 additions & 45 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/client": minor
3+
"@objectstack/rest": patch
4+
---
5+
6+
feat(client): close the sharing-rules (5) + security-explain (2) + search (1) REST gaps (#3587 batch 4/5)
7+
8+
New `client.shares.rules` sub-namespace for tenant-wide sharing rules
9+
(M10.17): `list` / `save` / `get` / `delete` (204-safe, grants cascade) /
10+
`evaluate` (reconcile). `client.security.explain` speaks the ADR-0090 D6
11+
access-explanation contract via the POST transport (the GET query form is the
12+
same `ExplainRequestSchema`). Top-level `client.search` covers global
13+
cross-object search (M10.5). REST route-ledger ratchet: 17 → 9.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/client": minor
3+
"@objectstack/rest": patch
4+
---
5+
6+
feat(client): close the final 9 REST gaps — ratchet 9 → 0 (#3587 batch 5/5)
7+
8+
`data.clone` (enable.clone duplication) and `data.export` (streaming
9+
CSV/JSON/XLSX; returns the raw `Response` — a file stream, not a JSON
10+
envelope). New `email.send` (IEmailService; branch on the returned `status`).
11+
`analytics.queryDataset` speaks the ADR-0021 REST dataset-query dialect. New
12+
`datasources.external.*` federation admin: `listTables` / `draft` / `import` /
13+
`refreshCatalog` / `validate` (ADR-0015 Addendum, 503-degrading). Every REST
14+
route is now either SDK-expressed or carries a reviewed non-sdk disposition —
15+
the #3587 gap ratchet rests at ZERO.

content/docs/api/client-sdk.mdx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,18 +110,21 @@ The `@objectstack/client` SDK aims to implement the ObjectStack API protocol spe
110110
|:----------|:------:|:--------|:--------|
111111
| **discovery** || 1 | API version & capabilities detection |
112112
| **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) |
113-
| **data** || 10 | CRUD & query operations |
113+
| **data** || 12 | CRUD & query operations, record clone, streaming export (M10.9) |
114114
| **auth** || 5 | Authentication & user management |
115115
| **packages** || 17 | Package lifecycle: install/enable, drafts (ADR-0033), commits & rollback (ADR-0067), export/duplicate (ADR-0070) |
116-
| **analytics** || 3 | Analytics queries |
116+
| **analytics** || 4 | Analytics queries + semantic-layer dataset query (ADR-0021) |
117117
| **automation** || 18 | Flow CRUD, trigger/execute, runs, screen-flow resume, descriptor/status registries |
118118
| **actions** || 2 | Server-registered action handlers (`engine.registerAction`) |
119119
| **approvals** || 12 | Approval requests (ADR-0019): inbox, decisions, recall, revise/resubmit (ADR-0044), thread interactions, audit trail |
120120
| **reports** || 8 | Saved reports: definitions, execution, recurring email schedules (501 without `@objectstack/plugin-reports`) |
121-
| **shares** || 3 | Per-record sharing grants: list/grant/revoke on `/data/:object/:id/shares` |
121+
| **shares** || 8 | Per-record sharing grants (list/grant/revoke) + tenant-wide sharing rules (`shares.rules.*`, M10.17) |
122+
| **search** || 1 | Global cross-object search (M10.5) |
123+
| **email** || 1 | Transactional send via IEmailService (M11.B1) |
124+
| **datasources** || 5 | External-datasource federation admin: browse/draft/import remote tables (ADR-0015) |
122125
| **keys** || 1 | API key minting (one-time secret) |
123126
| **shareLinks** || 3 | Record share-link management |
124-
| **security** || 3 | Suggested audience bindings (admin) |
127+
| **security** || 4 | Access explanation (ADR-0090 D6) + suggested audience bindings (admin) |
125128
| **storage** || 2 | File upload & download |
126129
| **i18n** || 3 | Internationalization |
127130
| **notifications** || 3 | List, mark-read, mark-all-read (inbox/receipt spine, ADR-0030) |

packages/client/src/client.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,121 @@ describe('Record shares namespace (#3587 gap closure)', () => {
318318
});
319319
});
320320

321+
describe('Sharing rules namespace (#3587 gap closure)', () => {
322+
it('shares.rules.list pins GET /sharing/rules with filters', async () => {
323+
const { client, fetchMock } = createMockClient({ data: [{ name: 'team_leads' }] });
324+
const rows = await client.shares.rules.list({ object: 'lead', activeOnly: true });
325+
expect(String(fetchMock.mock.calls[0][0])).toBe(
326+
'http://localhost:3000/api/v1/sharing/rules?object=lead&activeOnly=true',
327+
);
328+
expect(rows).toEqual([{ name: 'team_leads' }]);
329+
});
330+
331+
it('shares.rules.save pins POST /sharing/rules', async () => {
332+
const { client, fetchMock } = createMockClient({ name: 'team_leads' });
333+
await client.shares.rules.save({ name: 'team_leads', object: 'lead', accessLevel: 'read' });
334+
const [url, init] = fetchMock.mock.calls[0];
335+
expect(String(url)).toBe('http://localhost:3000/api/v1/sharing/rules');
336+
expect(init.method).toBe('POST');
337+
});
338+
339+
it('shares.rules.get / delete / evaluate pin the :idOrName routes', async () => {
340+
const { client, fetchMock } = createMockClient({ name: 'team_leads' });
341+
await client.shares.rules.get('team_leads');
342+
expect(String(fetchMock.mock.calls[0][0])).toBe('http://localhost:3000/api/v1/sharing/rules/team_leads');
343+
await client.shares.rules.evaluate('team_leads');
344+
expect(String(fetchMock.mock.calls[1][0])).toBe('http://localhost:3000/api/v1/sharing/rules/team_leads/evaluate');
345+
expect(fetchMock.mock.calls[1][1].method).toBe('POST');
346+
347+
const del = createMockClient(undefined, 204);
348+
del.fetchMock.mockResolvedValue({ ok: true, status: 204, statusText: 'No Content', json: async () => { throw new Error('no body'); }, headers: new Headers() });
349+
const out = await del.client.shares.rules.delete('team_leads');
350+
expect(String(del.fetchMock.mock.calls[0][0])).toBe('http://localhost:3000/api/v1/sharing/rules/team_leads');
351+
expect(out).toEqual({ deleted: true });
352+
});
353+
});
354+
355+
describe('Security explain & global search (#3587 gap closure)', () => {
356+
it('security.explain pins POST /security/explain with the request body', async () => {
357+
const { client, fetchMock } = createMockClient({ allowed: true });
358+
await client.security.explain({ object: 'lead', operation: 'update', userId: 'u1', recordId: 'r1' });
359+
const [url, init] = fetchMock.mock.calls[0];
360+
expect(String(url)).toBe('http://localhost:3000/api/v1/security/explain');
361+
expect(init.method).toBe('POST');
362+
expect(JSON.parse(init.body)).toEqual({ object: 'lead', operation: 'update', userId: 'u1', recordId: 'r1' });
363+
});
364+
365+
it('search pins GET /search with q/objects/limit/perObject', async () => {
366+
const { client, fetchMock } = createMockClient({ results: [] });
367+
await client.search('acme', { objects: ['lead', 'account'], limit: 20, perObject: 5 });
368+
expect(String(fetchMock.mock.calls[0][0])).toBe(
369+
'http://localhost:3000/api/v1/search?q=acme&objects=lead%2Caccount&limit=20&perObject=5',
370+
);
371+
});
372+
});
373+
374+
describe('Data actions, email, dataset query, external datasources (#3587 gap closure)', () => {
375+
it('data.clone pins POST /data/:object/:id/clone and nests overrides', async () => {
376+
const { client, fetchMock } = createMockClient({ id: 'new1' });
377+
await client.data.clone('lead', 'rec1', { name: 'Copy of Acme' });
378+
const [url, init] = fetchMock.mock.calls[0];
379+
expect(String(url)).toBe('http://localhost:3000/api/v1/data/lead/rec1/clone');
380+
expect(init.method).toBe('POST');
381+
expect(JSON.parse(init.body)).toEqual({ overrides: { name: 'Copy of Acme' } });
382+
});
383+
384+
it('data.export pins GET /data/:object/export and returns the raw Response', async () => {
385+
const { client, fetchMock } = createMockClient({});
386+
const res = await client.data.export('lead', { format: 'xlsx', limit: 100, filter: { status: 'open' }, orderby: 'name:asc', header: false });
387+
expect(String(fetchMock.mock.calls[0][0])).toBe(
388+
'http://localhost:3000/api/v1/data/lead/export?format=xlsx&limit=100&filter=%7B%22status%22%3A%22open%22%7D&orderby=name%3Aasc&header=false',
389+
);
390+
// A file stream, not a JSON envelope — the raw Response comes back.
391+
expect(typeof (res as any).json).toBe('function');
392+
});
393+
394+
it('email.send pins POST /email/send', async () => {
395+
const { client, fetchMock } = createMockClient({ status: 'sent', id: 'm1' });
396+
await client.email.send({ to: 'a@example.com', subject: 'Hello', text: 'hi' });
397+
const [url, init] = fetchMock.mock.calls[0];
398+
expect(String(url)).toBe('http://localhost:3000/api/v1/email/send');
399+
expect(init.method).toBe('POST');
400+
});
401+
402+
it('analytics.queryDataset pins POST /analytics/dataset/query', async () => {
403+
const { client, fetchMock } = createMockClient({ rows: [] });
404+
await client.analytics.queryDataset({ datasetName: 'sales', selection: { measures: ['amount_sum'] } });
405+
const [url, init] = fetchMock.mock.calls[0];
406+
expect(String(url)).toBe('http://localhost:3000/api/v1/analytics/dataset/query');
407+
expect(JSON.parse(init.body)).toEqual({ datasetName: 'sales', selection: { measures: ['amount_sum'] } });
408+
});
409+
410+
it('datasources.external.* pin the five federation-admin routes', async () => {
411+
const { client, fetchMock } = createMockClient({ tables: [] });
412+
await client.datasources.external.listTables('pg_main', { schema: 'public' });
413+
expect(String(fetchMock.mock.calls[0][0])).toBe(
414+
'http://localhost:3000/api/v1/datasources/pg_main/external/tables?schema=public',
415+
);
416+
await client.datasources.external.draft('pg_main', 'customers');
417+
expect(String(fetchMock.mock.calls[1][0])).toBe(
418+
'http://localhost:3000/api/v1/datasources/pg_main/external/tables/customers/draft',
419+
);
420+
await client.datasources.external.import('pg_main', 'customers', { namespace: 'crm' });
421+
expect(String(fetchMock.mock.calls[2][0])).toBe(
422+
'http://localhost:3000/api/v1/datasources/pg_main/external/tables/customers/import',
423+
);
424+
await client.datasources.external.refreshCatalog('pg_main');
425+
expect(String(fetchMock.mock.calls[3][0])).toBe(
426+
'http://localhost:3000/api/v1/datasources/pg_main/external/refresh-catalog',
427+
);
428+
await client.datasources.external.validate('pg_main');
429+
expect(String(fetchMock.mock.calls[4][0])).toBe(
430+
'http://localhost:3000/api/v1/datasources/pg_main/external/validate',
431+
);
432+
for (let i = 1; i <= 4; i++) expect(fetchMock.mock.calls[i][1].method).toBe('POST');
433+
});
434+
});
435+
321436
describe('Approvals namespace (ADR-0019)', () => {
322437
it('should list approval requests with filters', async () => {
323438
const { client, fetchMock } = createMockClient({

0 commit comments

Comments
 (0)