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
13 changes: 13 additions & 0 deletions .changeset/close-sharing-rules-explain-search-gaps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@objectstack/client": minor
"@objectstack/rest": patch
---

feat(client): close the sharing-rules (5) + security-explain (2) + search (1) REST gaps (#3587 batch 4/5)

New `client.shares.rules` sub-namespace for tenant-wide sharing rules
(M10.17): `list` / `save` / `get` / `delete` (204-safe, grants cascade) /
`evaluate` (reconcile). `client.security.explain` speaks the ADR-0090 D6
access-explanation contract via the POST transport (the GET query form is the
same `ExplainRequestSchema`). Top-level `client.search` covers global
cross-object search (M10.5). REST route-ledger ratchet: 17 → 9.
15 changes: 15 additions & 0 deletions .changeset/close-the-final-nine-rest-gaps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@objectstack/client": minor
"@objectstack/rest": patch
---

feat(client): close the final 9 REST gaps — ratchet 9 → 0 (#3587 batch 5/5)

`data.clone` (enable.clone duplication) and `data.export` (streaming
CSV/JSON/XLSX; returns the raw `Response` — a file stream, not a JSON
envelope). New `email.send` (IEmailService; branch on the returned `status`).
`analytics.queryDataset` speaks the ADR-0021 REST dataset-query dialect. New
`datasources.external.*` federation admin: `listTables` / `draft` / `import` /
`refreshCatalog` / `validate` (ADR-0015 Addendum, 503-degrading). Every REST
route is now either SDK-expressed or carries a reviewed non-sdk disposition —
the #3587 gap ratchet rests at ZERO.
11 changes: 7 additions & 4 deletions content/docs/api/client-sdk.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -110,18 +110,21 @@ The `@objectstack/client` SDK aims to implement the ObjectStack API protocol spe
|:----------|:------:|:--------|:--------|
| **discovery** | ✅ | 1 | API version & capabilities detection |
| **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 |
| **data** | ✅ | 12 | CRUD & query operations, record clone, streaming export (M10.9) |
| **auth** | ✅ | 5 | Authentication & user management |
| **packages** | ✅ | 17 | Package lifecycle: install/enable, drafts (ADR-0033), commits & rollback (ADR-0067), export/duplicate (ADR-0070) |
| **analytics** | ✅ | 3 | Analytics queries |
| **analytics** | ✅ | 4 | Analytics queries + semantic-layer dataset query (ADR-0021) |
| **automation** | ✅ | 18 | Flow CRUD, trigger/execute, runs, screen-flow resume, descriptor/status registries |
| **actions** | ✅ | 2 | Server-registered action handlers (`engine.registerAction`) |
| **approvals** | ✅ | 12 | Approval requests (ADR-0019): inbox, decisions, recall, revise/resubmit (ADR-0044), thread interactions, audit trail |
| **reports** | ✅ | 8 | Saved reports: definitions, execution, recurring email schedules (501 without `@objectstack/plugin-reports`) |
| **shares** | ✅ | 3 | Per-record sharing grants: list/grant/revoke on `/data/:object/:id/shares` |
| **shares** | ✅ | 8 | Per-record sharing grants (list/grant/revoke) + tenant-wide sharing rules (`shares.rules.*`, M10.17) |
| **search** | ✅ | 1 | Global cross-object search (M10.5) |
| **email** | ✅ | 1 | Transactional send via IEmailService (M11.B1) |
| **datasources** | ✅ | 5 | External-datasource federation admin: browse/draft/import remote tables (ADR-0015) |
| **keys** | ✅ | 1 | API key minting (one-time secret) |
| **shareLinks** | ✅ | 3 | Record share-link management |
| **security** | ✅ | 3 | Suggested audience bindings (admin) |
| **security** | ✅ | 4 | Access explanation (ADR-0090 D6) + suggested audience bindings (admin) |
| **storage** | ✅ | 2 | File upload & download |
| **i18n** | ✅ | 3 | Internationalization |
| **notifications** | ✅ | 3 | List, mark-read, mark-all-read (inbox/receipt spine, ADR-0030) |
Expand Down
115 changes: 115 additions & 0 deletions packages/client/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,121 @@ describe('Record shares namespace (#3587 gap closure)', () => {
});
});

describe('Sharing rules namespace (#3587 gap closure)', () => {
it('shares.rules.list pins GET /sharing/rules with filters', async () => {
const { client, fetchMock } = createMockClient({ data: [{ name: 'team_leads' }] });
const rows = await client.shares.rules.list({ object: 'lead', activeOnly: true });
expect(String(fetchMock.mock.calls[0][0])).toBe(
'http://localhost:3000/api/v1/sharing/rules?object=lead&activeOnly=true',
);
expect(rows).toEqual([{ name: 'team_leads' }]);
});

it('shares.rules.save pins POST /sharing/rules', async () => {
const { client, fetchMock } = createMockClient({ name: 'team_leads' });
await client.shares.rules.save({ name: 'team_leads', object: 'lead', accessLevel: 'read' });
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toBe('http://localhost:3000/api/v1/sharing/rules');
expect(init.method).toBe('POST');
});

it('shares.rules.get / delete / evaluate pin the :idOrName routes', async () => {
const { client, fetchMock } = createMockClient({ name: 'team_leads' });
await client.shares.rules.get('team_leads');
expect(String(fetchMock.mock.calls[0][0])).toBe('http://localhost:3000/api/v1/sharing/rules/team_leads');
await client.shares.rules.evaluate('team_leads');
expect(String(fetchMock.mock.calls[1][0])).toBe('http://localhost:3000/api/v1/sharing/rules/team_leads/evaluate');
expect(fetchMock.mock.calls[1][1].method).toBe('POST');

const del = createMockClient(undefined, 204);
del.fetchMock.mockResolvedValue({ ok: true, status: 204, statusText: 'No Content', json: async () => { throw new Error('no body'); }, headers: new Headers() });
const out = await del.client.shares.rules.delete('team_leads');
expect(String(del.fetchMock.mock.calls[0][0])).toBe('http://localhost:3000/api/v1/sharing/rules/team_leads');
expect(out).toEqual({ deleted: true });
});
});

describe('Security explain & global search (#3587 gap closure)', () => {
it('security.explain pins POST /security/explain with the request body', async () => {
const { client, fetchMock } = createMockClient({ allowed: true });
await client.security.explain({ object: 'lead', operation: 'update', userId: 'u1', recordId: 'r1' });
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toBe('http://localhost:3000/api/v1/security/explain');
expect(init.method).toBe('POST');
expect(JSON.parse(init.body)).toEqual({ object: 'lead', operation: 'update', userId: 'u1', recordId: 'r1' });
});

it('search pins GET /search with q/objects/limit/perObject', async () => {
const { client, fetchMock } = createMockClient({ results: [] });
await client.search('acme', { objects: ['lead', 'account'], limit: 20, perObject: 5 });
expect(String(fetchMock.mock.calls[0][0])).toBe(
'http://localhost:3000/api/v1/search?q=acme&objects=lead%2Caccount&limit=20&perObject=5',
);
});
});

describe('Data actions, email, dataset query, external datasources (#3587 gap closure)', () => {
it('data.clone pins POST /data/:object/:id/clone and nests overrides', async () => {
const { client, fetchMock } = createMockClient({ id: 'new1' });
await client.data.clone('lead', 'rec1', { name: 'Copy of Acme' });
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toBe('http://localhost:3000/api/v1/data/lead/rec1/clone');
expect(init.method).toBe('POST');
expect(JSON.parse(init.body)).toEqual({ overrides: { name: 'Copy of Acme' } });
});

it('data.export pins GET /data/:object/export and returns the raw Response', async () => {
const { client, fetchMock } = createMockClient({});
const res = await client.data.export('lead', { format: 'xlsx', limit: 100, filter: { status: 'open' }, orderby: 'name:asc', header: false });
expect(String(fetchMock.mock.calls[0][0])).toBe(
'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',
);
// A file stream, not a JSON envelope — the raw Response comes back.
expect(typeof (res as any).json).toBe('function');
});

it('email.send pins POST /email/send', async () => {
const { client, fetchMock } = createMockClient({ status: 'sent', id: 'm1' });
await client.email.send({ to: 'a@example.com', subject: 'Hello', text: 'hi' });
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toBe('http://localhost:3000/api/v1/email/send');
expect(init.method).toBe('POST');
});

it('analytics.queryDataset pins POST /analytics/dataset/query', async () => {
const { client, fetchMock } = createMockClient({ rows: [] });
await client.analytics.queryDataset({ datasetName: 'sales', selection: { measures: ['amount_sum'] } });
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toBe('http://localhost:3000/api/v1/analytics/dataset/query');
expect(JSON.parse(init.body)).toEqual({ datasetName: 'sales', selection: { measures: ['amount_sum'] } });
});

it('datasources.external.* pin the five federation-admin routes', async () => {
const { client, fetchMock } = createMockClient({ tables: [] });
await client.datasources.external.listTables('pg_main', { schema: 'public' });
expect(String(fetchMock.mock.calls[0][0])).toBe(
'http://localhost:3000/api/v1/datasources/pg_main/external/tables?schema=public',
);
await client.datasources.external.draft('pg_main', 'customers');
expect(String(fetchMock.mock.calls[1][0])).toBe(
'http://localhost:3000/api/v1/datasources/pg_main/external/tables/customers/draft',
);
await client.datasources.external.import('pg_main', 'customers', { namespace: 'crm' });
expect(String(fetchMock.mock.calls[2][0])).toBe(
'http://localhost:3000/api/v1/datasources/pg_main/external/tables/customers/import',
);
await client.datasources.external.refreshCatalog('pg_main');
expect(String(fetchMock.mock.calls[3][0])).toBe(
'http://localhost:3000/api/v1/datasources/pg_main/external/refresh-catalog',
);
await client.datasources.external.validate('pg_main');
expect(String(fetchMock.mock.calls[4][0])).toBe(
'http://localhost:3000/api/v1/datasources/pg_main/external/validate',
);
for (let i = 1; i <= 4; i++) expect(fetchMock.mock.calls[i][1].method).toBe('POST');
});
});

describe('Approvals namespace (ADR-0019)', () => {
it('should list approval requests with filters', async () => {
const { client, fetchMock } = createMockClient({
Expand Down
Loading
Loading