From 40f01d936cfd9e0d8be994035bc618d8195f8593 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 11:19:25 +0000 Subject: [PATCH] =?UTF-8?q?feat(client):=20close=20the=20final=2017=20REST?= =?UTF-8?q?=20gaps=20=E2=80=94=20ratchet=2017=E2=86=920=20(#3587=20batches?= =?UTF-8?q?=204+5/5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LX9ut3MK3KykE11S9bJmv5 --- ...close-sharing-rules-explain-search-gaps.md | 13 + .changeset/close-the-final-nine-rest-gaps.md | 15 + content/docs/api/client-sdk.mdx | 11 +- packages/client/src/client.test.ts | 115 ++++++++ packages/client/src/index.ts | 256 ++++++++++++++++++ .../src/rest-route-ledger.conformance.test.ts | 14 +- packages/rest/src/rest-route-ledger.ts | 53 ++-- 7 files changed, 432 insertions(+), 45 deletions(-) create mode 100644 .changeset/close-sharing-rules-explain-search-gaps.md create mode 100644 .changeset/close-the-final-nine-rest-gaps.md diff --git a/.changeset/close-sharing-rules-explain-search-gaps.md b/.changeset/close-sharing-rules-explain-search-gaps.md new file mode 100644 index 0000000000..de3d6abed3 --- /dev/null +++ b/.changeset/close-sharing-rules-explain-search-gaps.md @@ -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. diff --git a/.changeset/close-the-final-nine-rest-gaps.md b/.changeset/close-the-final-nine-rest-gaps.md new file mode 100644 index 0000000000..6f5bb132c0 --- /dev/null +++ b/.changeset/close-the-final-nine-rest-gaps.md @@ -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. diff --git a/content/docs/api/client-sdk.mdx b/content/docs/api/client-sdk.mdx index 4fbfd2e7f4..6a2bac5989 100644 --- a/content/docs/api/client-sdk.mdx +++ b/content/docs/api/client-sdk.mdx @@ -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) | diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index 3478dac894..24ddeaeeb2 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -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({ diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index f9f6be1b25..041ae6ac5a 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -674,9 +674,119 @@ export class ObjectStackClient { body: JSON.stringify(payload) }); return res.json(); + }, + /** + * ADR-0021 semantic-layer dataset query — the REST dialect + * (`POST /analytics/dataset/query`), distinct from `query`'s dispatcher + * dialect. Provide `dataset` (inline definition, Studio preview) or + * `datasetName` (saved), plus `selection.measures`; `previewDrafts` + * runs over draft-overlaid definitions (ADR-0037 P3). (#3587 gap closure) + */ + queryDataset: async (payload: { + dataset?: any; + datasetName?: string; + selection: { measures: string[]; [k: string]: any }; + previewDrafts?: boolean; + }) => { + const route = this.getRoute('analytics'); + const res = await this.fetch(`${this.baseUrl}${route}/dataset/query`, { + method: 'POST', + body: JSON.stringify(payload), + }); + return res.json(); } }; + /** + * Transactional email (M11.B1) — `IEmailService` behind the REST surface. + * 501s cleanly on deployments without an email provider. (#3587 gap closure) + */ + email = { + /** + * Send a message. Returns the service outcome verbatim — branch on + * `status` (`sent` / `queued` / `failed`); the route answers 200 for all + * three so failures carry their diagnostic body. `sentBy` defaults + * server-side to the authenticated user. + */ + send: async (message: { + to: string | Array; + subject: string; + text?: string; + html?: string; + from?: any; + cc?: any; + bcc?: any; + replyTo?: any; + sentBy?: string; + [k: string]: any; + }): Promise => { + const res = await this.fetch(`${this.baseUrl}/api/v1/email/send`, { + method: 'POST', + body: JSON.stringify(message), + }); + return this.unwrapResponse(res); + }, + }; + + /** + * External-datasource federation admin (ADR-0015 Addendum) — the + * direct-mount routes `@objectstack/rest` registers for browsing a remote + * catalog and importing tables as federated objects. 503 + * [external_service_unavailable] without the `external-datasource` + * service. (#3587 gap closure) + */ + datasources = { + external: { + /** List remote tables on a datasource, optionally by `schema`. */ + listTables: async (name: string, opts?: { schema?: string }): Promise => { + const qs = opts?.schema ? `?schema=${encodeURIComponent(opts.schema)}` : ''; + const res = await this.fetch( + `${this.baseUrl}/api/v1/datasources/${encodeURIComponent(name)}/external/tables${qs}`, + ); + return this.unwrapResponse(res); + }, + + /** Generate an Object draft (structured + `*.object.ts` source) from a remote table. */ + draft: async (name: string, remoteTable: string, opts?: Record): Promise => { + const res = await this.fetch( + `${this.baseUrl}/api/v1/datasources/${encodeURIComponent(name)}/external/tables/${encodeURIComponent(remoteTable)}/draft`, + { method: 'POST', body: JSON.stringify(opts ?? {}) }, + ); + return this.unwrapResponse(res); + }, + + /** + * Import a remote table as a live federated object ("Import as + * Object"). 400 [external_import_error] when refused. + */ + import: async (name: string, remoteTable: string, opts?: Record): Promise => { + const res = await this.fetch( + `${this.baseUrl}/api/v1/datasources/${encodeURIComponent(name)}/external/tables/${encodeURIComponent(remoteTable)}/import`, + { method: 'POST', body: JSON.stringify(opts ?? {}) }, + ); + return this.unwrapResponse(res); + }, + + /** Refresh and return the cached remote-catalog snapshot. */ + refreshCatalog: async (name: string): Promise => { + const res = await this.fetch( + `${this.baseUrl}/api/v1/datasources/${encodeURIComponent(name)}/external/refresh-catalog`, + { method: 'POST', body: JSON.stringify({}) }, + ); + return this.unwrapResponse(res); + }, + + /** Validate this datasource's federated objects against the remote schema. */ + validate: async (name: string): Promise => { + const res = await this.fetch( + `${this.baseUrl}/api/v1/datasources/${encodeURIComponent(name)}/external/validate`, + { method: 'POST', body: JSON.stringify({}) }, + ); + return this.unwrapResponse(res); + }, + }, + }; + /** * Package Management Services * @@ -2801,6 +2911,27 @@ export class ObjectStackClient { * `security` is not in `ApiRoutesSchema`. */ security = { + /** + * ADR-0090 D6 access explanation: why a principal can (or cannot) + * perform `operation` on `object` — the same code paths enforcement + * runs, so the report is explained by construction. Explaining ANOTHER + * user requires `manage_users` (403 otherwise); `recordId` narrows to + * one concrete row (ADR-0095). Sent via the POST transport; the GET + * query form is the same contract. (#3587 gap closure) + */ + explain: async (request: { + object: string; + operation?: 'read' | 'create' | 'update' | 'delete' | 'transfer' | 'restore' | 'purge'; + userId?: string; + recordId?: string; + }): Promise => { + const res = await this.fetch(`${this.baseUrl}/api/v1/security/explain`, { + method: 'POST', + body: JSON.stringify(request), + }); + return this.unwrapResponse(res); + }, + suggestedBindings: { /** List suggestions, optionally by `status` / `packageId` (reconciles first). */ list: async (opts?: { status?: string; packageId?: string }): Promise => { @@ -3074,6 +3205,85 @@ export class ObjectStackClient { if (res.status === 204) return { deleted: true }; return this.unwrapResponse<{ deleted: boolean }>(res); }, + + /** + * Tenant-wide sharing RULES (M10.17) — criteria-based grants that + * materialise into per-record shares. Top-of-surface admin routes + * (`/api/v1/sharing/rules`), distinct from the per-record grants above. + */ + rules: { + /** List sharing rules, optionally by object / active-only. */ + list: async (opts?: { object?: string; activeOnly?: boolean }): Promise => { + const params = new URLSearchParams(); + if (opts?.object) params.set('object', opts.object); + if (opts?.activeOnly !== undefined) params.set('activeOnly', String(opts.activeOnly)); + const qs = params.toString(); + const res = await this.fetch(`${this.baseUrl}/api/v1/sharing/rules${qs ? `?${qs}` : ''}`); + const body = await this.unwrapResponse<{ data?: any[] } | any[]>(res); + return Array.isArray(body) ? body : (body?.data ?? []); + }, + + /** Create or upsert a sharing rule. 400 [VALIDATION_FAILED] on a bad definition. */ + save: async (rule: { + name: string; + object: string; + criteria?: any; + recipientType?: string; + recipientId?: string; + accessLevel?: string; + label?: string; + description?: string; + active?: boolean; + }): Promise => { + const res = await this.fetch(`${this.baseUrl}/api/v1/sharing/rules`, { + method: 'POST', + body: JSON.stringify(rule), + }); + return this.unwrapResponse(res); + }, + + /** Get a sharing rule by id or name. 404 [RULE_NOT_FOUND] when absent. */ + get: async (idOrName: string): Promise => { + const res = await this.fetch(`${this.baseUrl}/api/v1/sharing/rules/${encodeURIComponent(idOrName)}`); + return this.unwrapResponse(res); + }, + + /** Delete a sharing rule; its materialised grants cascade. */ + delete: async (idOrName: string): Promise<{ deleted: boolean }> => { + const res = await this.fetch(`${this.baseUrl}/api/v1/sharing/rules/${encodeURIComponent(idOrName)}`, { + method: 'DELETE', + }); + if (res.status === 204) return { deleted: true }; + return this.unwrapResponse<{ deleted: boolean }>(res); + }, + + /** Re-evaluate a rule against current data and reconcile its grants. */ + evaluate: async (idOrName: string): Promise => { + const res = await this.fetch(`${this.baseUrl}/api/v1/sharing/rules/${encodeURIComponent(idOrName)}/evaluate`, { + method: 'POST', + body: JSON.stringify({}), + }); + return this.unwrapResponse(res); + }, + }, + }; + + /** + * Global cross-object search (M10.5): one query across every searchable + * object the caller can read. 501s on kernels without `searchAll`. + * (#3587 gap closure) + */ + search = async ( + q: string, + opts?: { objects?: string[]; limit?: number; perObject?: number }, + ): Promise => { + const params = new URLSearchParams(); + params.set('q', q); + if (opts?.objects?.length) params.set('objects', opts.objects.join(',')); + if (opts?.limit !== undefined) params.set('limit', String(opts.limit)); + if (opts?.perObject !== undefined) params.set('perObject', String(opts.perObject)); + const res = await this.fetch(`${this.baseUrl}/api/v1/search?${params.toString()}`); + return this.unwrapResponse(res); }; /** @@ -3642,6 +3852,52 @@ export class ObjectStackClient { body: JSON.stringify(request) }); return this.unwrapResponse(res); + }, + + /** + * Duplicate a record (gated by the object's `enable.clone` capability). + * `overrides` are applied on top of the copied values — e.g. a new name + * or a cleared unique field. (#3587 gap closure) + */ + clone: async (object: string, id: string, overrides?: Record): Promise => { + const route = this.getRoute('data'); + const res = await this.fetch( + `${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(id)}/clone`, + { method: 'POST', body: JSON.stringify(overrides ? { overrides } : {}) }, + ); + return this.unwrapResponse(res); + }, + + /** + * Streaming export (M10.9): CSV / JSON / XLSX file download. Returns the + * raw `Response` — the body is a file stream (`Content-Disposition` + * attachment), not a JSON envelope; call `.blob()` / `.text()` yourself. + * `filter` is JSON-encoded into the query; `orderby` accepts the + * `field:dir,field2:dir` shorthand or an object. (#3587 gap closure) + */ + export: async ( + object: string, + opts?: { + format?: 'csv' | 'json' | 'xlsx'; + limit?: number; + filter?: any; + orderby?: string | Record; + header?: boolean; + }, + ): Promise => { + const route = this.getRoute('data'); + const params = new URLSearchParams(); + if (opts?.format) params.set('format', opts.format); + if (opts?.limit !== undefined) params.set('limit', String(opts.limit)); + if (opts?.filter !== undefined) { + params.set('filter', typeof opts.filter === 'string' ? opts.filter : JSON.stringify(opts.filter)); + } + if (opts?.orderby !== undefined) { + params.set('orderby', typeof opts.orderby === 'string' ? opts.orderby : JSON.stringify(opts.orderby)); + } + if (opts?.header !== undefined) params.set('header', String(opts.header)); + const qs = params.toString(); + return this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(object)}/export${qs ? `?${qs}` : ''}`); } }; diff --git a/packages/rest/src/rest-route-ledger.conformance.test.ts b/packages/rest/src/rest-route-ledger.conformance.test.ts index bfdb04a5d2..cd02ffa037 100644 --- a/packages/rest/src/rest-route-ledger.conformance.test.ts +++ b/packages/rest/src/rest-route-ledger.conformance.test.ts @@ -144,13 +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; 17 after the - // metadata (9), reports (8), and approvals+record-shares (9) batches - // (data-actions 2, search 1, email 1, analytics 1, security-explain 2, - // sharing-rules 5, external-datasource 5 remain). 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, closed to ZERO + // across five batches (metadata 9, reports 8, approvals+record-shares 9, + // sharing-rules+security-explain+search 8, data-actions+email+analytics+ + // external-datasource 9). Every REST route is now either SDK-expressed + // or carries a reviewed non-sdk disposition. A new `gap` row demands an + // explicit, reviewed decision — this bound stays 0. const gaps = REST_ROUTE_LEDGER.filter((e) => e.disposition === 'gap').length; - expect(gaps).toBeLessThanOrEqual(17); + expect(gaps).toBeLessThanOrEqual(0); }); }); diff --git a/packages/rest/src/rest-route-ledger.ts b/packages/rest/src/rest-route-ledger.ts index 3668ba0410..5352aba71a 100644 --- a/packages/rest/src/rest-route-ledger.ts +++ b/packages/rest/src/rest-route-ledger.ts @@ -114,8 +114,7 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ { route: 'DELETE /api/v1/data/:object/:id', family: 'crud', source: 'route-manager', disposition: 'sdk', client: 'data.delete' }, // ── data actions (clone / import / import jobs / export) ────────────────── - { route: 'POST /api/v1/data/:object/:id/clone', family: 'data-actions', source: 'route-manager', disposition: 'gap', - note: 'record clone (object enable.clone) with no SDK expression' }, + { route: 'POST /api/v1/data/:object/:id/clone', family: 'data-actions', source: 'route-manager', disposition: 'sdk', client: 'data.clone' }, { route: 'POST /api/v1/data/:object/import', family: 'data-actions', source: 'route-manager', disposition: 'sdk', client: 'data.import' }, { route: 'POST /api/v1/data/:object/import/jobs', family: 'data-actions', source: 'route-manager', disposition: 'sdk', client: 'data.createImportJob' }, { route: 'POST /api/v1/data/import/jobs/:jobId/cancel', family: 'data-actions', source: 'route-manager', disposition: 'sdk', client: 'data.cancelImportJob' }, @@ -123,16 +122,14 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ { route: 'GET /api/v1/data/import/jobs/:jobId/results', family: 'data-actions', source: 'route-manager', disposition: 'sdk', client: 'data.getImportJobResults' }, { route: 'GET /api/v1/data/import/jobs/:jobId', family: 'data-actions', source: 'route-manager', disposition: 'sdk', client: 'data.getImportJobProgress' }, { route: 'GET /api/v1/data/import/jobs', family: 'data-actions', source: 'route-manager', disposition: 'sdk', client: 'data.listImportJobs' }, - { route: 'GET /api/v1/data/:object/export', family: 'data-actions', source: 'route-manager', disposition: 'gap', - note: 'streaming CSV/JSON/XLSX export with no SDK expression' }, + { route: 'GET /api/v1/data/:object/export', family: 'data-actions', source: 'route-manager', disposition: 'sdk', client: 'data.export', + note: 'file-stream response; the SDK returns the raw Response rather than a JSON envelope' }, // ── search ──────────────────────────────────────────────────────────────── - { route: 'GET /api/v1/search', family: 'search', source: 'route-manager', disposition: 'gap', - note: 'global cross-object search with no SDK expression' }, + { route: 'GET /api/v1/search', family: 'search', source: 'route-manager', disposition: 'sdk', client: 'search' }, // ── email ───────────────────────────────────────────────────────────────── - { route: 'POST /api/v1/email/send', family: 'email', source: 'route-manager', disposition: 'gap', - note: 'transactional send via EmailService with no SDK expression' }, + { route: 'POST /api/v1/email/send', family: 'email', source: 'route-manager', disposition: 'sdk', client: 'email.send' }, // ── public forms ────────────────────────────────────────────────────────── { route: 'GET /api/v1/forms/:slug', family: 'forms', source: 'route-manager', disposition: 'public', @@ -143,14 +140,12 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ note: 'anonymous scoped lookup picker (publicPicker-gated)' }, // ── analytics (semantic layer) ──────────────────────────────────────────── - { route: 'POST /api/v1/analytics/dataset/query', family: 'analytics', source: 'route-manager', disposition: 'gap', - note: 'ADR-0021 dataset preview/query with no SDK expression (analytics.query speaks the dispatcher dialect)' }, + { route: 'POST /api/v1/analytics/dataset/query', family: 'analytics', source: 'route-manager', disposition: 'sdk', client: 'analytics.queryDataset' }, // ── security explain (ADR-0090 D6) ──────────────────────────────────────── - { route: 'GET /api/v1/security/explain', family: 'security-explain', source: 'route-manager', disposition: 'gap', - note: 'authorization explain (query form) with no SDK expression' }, - { route: 'POST /api/v1/security/explain', family: 'security-explain', source: 'route-manager', disposition: 'gap', - note: 'authorization explain (body form) with no SDK expression' }, + { route: 'GET /api/v1/security/explain', family: 'security-explain', source: 'route-manager', disposition: 'sdk', client: 'security.explain', + note: 'query transport of the same ExplainRequestSchema contract; the SDK speaks the POST form' }, + { route: 'POST /api/v1/security/explain', family: 'security-explain', source: 'route-manager', disposition: 'sdk', client: 'security.explain' }, // ── per-record shares ───────────────────────────────────────────────────── { route: 'GET /api/v1/data/:object/:id/shares', family: 'record-shares', source: 'route-manager', disposition: 'sdk', client: 'shares.list' }, @@ -158,16 +153,11 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ { route: 'DELETE /api/v1/data/:object/:id/shares/:shareId', family: 'record-shares', source: 'route-manager', disposition: 'sdk', client: 'shares.revoke' }, // ── sharing rules ───────────────────────────────────────────────────────── - { route: 'GET /api/v1/sharing/rules', family: 'sharing-rules', source: 'route-manager', disposition: 'gap', - note: 'sharing-rule listing with no SDK expression' }, - { route: 'POST /api/v1/sharing/rules', family: 'sharing-rules', source: 'route-manager', disposition: 'gap', - note: 'sharing-rule upsert with no SDK expression' }, - { route: 'GET /api/v1/sharing/rules/:idOrName', family: 'sharing-rules', source: 'route-manager', disposition: 'gap', - note: 'sharing-rule read with no SDK expression' }, - { route: 'DELETE /api/v1/sharing/rules/:idOrName', family: 'sharing-rules', source: 'route-manager', disposition: 'gap', - note: 'sharing-rule delete (cascades materialised grants) with no SDK expression' }, - { route: 'POST /api/v1/sharing/rules/:idOrName/evaluate', family: 'sharing-rules', source: 'route-manager', disposition: 'gap', - note: 'sharing-rule re-evaluation/reconcile with no SDK expression' }, + { route: 'GET /api/v1/sharing/rules', family: 'sharing-rules', source: 'route-manager', disposition: 'sdk', client: 'shares.rules.list' }, + { route: 'POST /api/v1/sharing/rules', family: 'sharing-rules', source: 'route-manager', disposition: 'sdk', client: 'shares.rules.save' }, + { route: 'GET /api/v1/sharing/rules/:idOrName', family: 'sharing-rules', source: 'route-manager', disposition: 'sdk', client: 'shares.rules.get' }, + { route: 'DELETE /api/v1/sharing/rules/:idOrName', family: 'sharing-rules', source: 'route-manager', disposition: 'sdk', client: 'shares.rules.delete' }, + { route: 'POST /api/v1/sharing/rules/:idOrName/evaluate', family: 'sharing-rules', source: 'route-manager', disposition: 'sdk', client: 'shares.rules.evaluate' }, // ── security suggested-bindings (ADR-0090 D5/D9) ────────────────────────── { route: 'GET /api/v1/security/suggested-bindings', family: 'security', source: 'route-manager', disposition: 'sdk', client: 'security.suggestedBindings.list', @@ -219,14 +209,9 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ note: 'shadows the dispatcher twin (registered first); full uninstall via protocol.deletePackage (#2747)' }, // ── external datasource federation (ADR-0015 §6.2, direct-mount) ────────── - { route: 'GET /api/v1/datasources/:name/external/tables', family: 'external-datasource', source: 'direct-mount', disposition: 'gap', - note: 'remote-table listing with no SDK expression (503-degrading when federation absent)' }, - { route: 'POST /api/v1/datasources/:name/external/tables/:remote/draft', family: 'external-datasource', source: 'direct-mount', disposition: 'gap', - note: 'object-draft generation with no SDK expression' }, - { route: 'POST /api/v1/datasources/:name/external/tables/:remote/import', family: 'external-datasource', source: 'direct-mount', disposition: 'gap', - note: 'import-as-federated-object with no SDK expression' }, - { route: 'POST /api/v1/datasources/:name/external/refresh-catalog', family: 'external-datasource', source: 'direct-mount', disposition: 'gap', - note: 'catalog refresh with no SDK expression' }, - { route: 'POST /api/v1/datasources/:name/external/validate', family: 'external-datasource', source: 'direct-mount', disposition: 'gap', - note: 'federated-object validation with no SDK expression' }, + { route: 'GET /api/v1/datasources/:name/external/tables', family: 'external-datasource', source: 'direct-mount', disposition: 'sdk', client: 'datasources.external.listTables' }, + { route: 'POST /api/v1/datasources/:name/external/tables/:remote/draft', family: 'external-datasource', source: 'direct-mount', disposition: 'sdk', client: 'datasources.external.draft' }, + { route: 'POST /api/v1/datasources/:name/external/tables/:remote/import', family: 'external-datasource', source: 'direct-mount', disposition: 'sdk', client: 'datasources.external.import' }, + { route: 'POST /api/v1/datasources/:name/external/refresh-catalog', family: 'external-datasource', source: 'direct-mount', disposition: 'sdk', client: 'datasources.external.refreshCatalog' }, + { route: 'POST /api/v1/datasources/:name/external/validate', family: 'external-datasource', source: 'direct-mount', disposition: 'sdk', client: 'datasources.external.validate' }, ];