diff --git a/.changeset/client-precise-sdk-return-types.md b/.changeset/client-precise-sdk-return-types.md new file mode 100644 index 0000000000..05e1a4990b --- /dev/null +++ b/.changeset/client-precise-sdk-return-types.md @@ -0,0 +1,56 @@ +--- +'@objectstack/client': minor +--- + +Bind the SDK's erased return types to the `@objectstack/spec` contracts the package already depends on + +**This is a NARROWING of published return types.** 41 methods that resolved to `any` (or to an +envelope carrying an `any[]`) now resolve to the contract type the route actually answers, and 12 +fixed-shape `automation.*` methods gain a constrained generic in place of ``. Nothing +changes at runtime — no request, response, unwrapping or error path is touched — but code that +compiles today against these methods can stop compiling. `any` is assignable to everything and +admits every property read, so the previous declaration accepted assignments, property reads and +parameter forwarding that a precise type refuses. + +What a consumer could stop compiling against, per family: + +- **`automation`** — `get`/`getFlow` are `FlowParsed`; `runs.get`/`getRun` are `ExecutionLog`; + `runs.list`/`listRuns` are `{ runs: ExecutionLog[]; hasMore: boolean }`; `execute` and `resume` + are `AutomationResult`; `getScreen` is `{ runId: string; screen: ScreenSpec }`; `listActions` and + `listConnectors` carry `ActionDescriptor[]` / `ConnectorDescriptor[]` instead of `any[]`. ⚠️ The + biggest practical break is `AutomationResult.screen`, `.runId`, `.status` and `.summary` being + **optional**: a completed run carries no screen, so `result.screen.nodeId` must become + `result.screen?.nodeId`. The six flat aliases and their `ScopedProjectClient` mirrors move + together. ⚠️ `` became `` on those twelve: an explicit type argument + still works when it narrows the platform shape (`getFlow`), + but one naming an unrelated type is now refused — including where TypeScript used to infer it + from the assignment's own annotation. +- **`approvals`** — `recall` / `revise` / `resubmit` are `ApprovalRecallResult` / + `ApprovalSendBackResult` / `ApprovalResubmitResult`; `remind` is + `{ request: ApprovalRequestRow; notified: number }`; `requestInfo` and `comment` are + `{ request: ApprovalRequestRow }`. These join `reassign` and `listActions`, which were already + typed this way beside them. +- **`shares` / `shareLinks`** — `shares.list` is `RecordShare[]`, `shares.grant` is `RecordShare`; + `shares.rules.list` / `save` / `get` are `SharingRuleRow`(`[]`) and `rules.evaluate` is + `SharingRuleEvaluationResult`; `shareLinks.create` / `list` are `ShareLink`(`[]`). +- **`reports`** — `list` / `save` / `get` are `SavedReport`(`[]`), `run` is `ReportRunResult`, + `schedule` / `listSchedules` are `ReportSchedule`(`[]`). +- **`security`** — `describeDelegableScope` is `DelegableScope`; `explain` is `ExplainDecision` (the + `z.input` form `ISecurityService.explain` declares and the route relays verbatim — **not** the + post-parse `ExplainDecisionParsed`, since no parse runs on that path); the three + `suggestedBindings` methods carry their `{ suggestion, … }` / `{ suggestions, synced }` envelopes. + The suggestion ROW stays `Record` by contract, but `bindingCreated` and + `synced.{created,confirmedObserved,pruned}` stop being erased. +- **`email` / `datasources.external`** — `email.send` is `SendEmailResult` (branch on `status`). + ⚠️ The four federation methods are **envelope-wrapped** and the obvious binding is the wrong one: + `listTables` answers `{ tables: RemoteTable[] }`, not `RemoteTable[]`; likewise + `{ draft: ObjectDraft }`, `{ object: ImportObjectResult }`, `{ catalog: ExternalCatalog }`. + `validate` is a bare `SchemaValidationReport`. +- **`ScopedProjectClient.packages.list`** — `{ packages: InstalledPackage[]; total: number }`. + +Four methods deliberately keep `Promise` and say so in their docblocks: `automation.create` / +`automation.update` echo an unvalidated request body, and `search` / `data.clone` answer shapes +declared inline in the implementation rather than in `@objectstack/spec`. Those are missing +*contracts*, not missing annotations, and authoring them belongs to the spec package. The +caller-supplied generics on `data.*` and `actions.*` are unchanged — there the payload really is +the caller's. diff --git a/content/docs/api/client-sdk.mdx b/content/docs/api/client-sdk.mdx index 6ed4f6ef5a..42a0a12ff7 100644 --- a/content/docs/api/client-sdk.mdx +++ b/content/docs/api/client-sdk.mdx @@ -400,7 +400,9 @@ try { // `{ status: 'paused', runId, screen }`; render the screen, then resume the run // with the collected values. A wizard pauses again for each further step. const run = await client.automation.execute('convert_lead', { params: { recordId } }); -if (run.status === 'paused') { +// `execute` returns `AutomationResult`, on which `runId` and `screen` are +// OPTIONAL — a run that COMPLETED carries neither — so narrow before resuming. +if (run.status === 'paused' && run.runId) { await client.automation.resume('convert_lead', run.runId, { inputs: { account_name: 'Radium Labs' }, }); @@ -438,7 +440,10 @@ await client.shareLinks.revoke(link.token); // Security (admin) — resolve package audience-binding suggestions (ADR-0090) const { suggestions } = await client.security.suggestedBindings.list({ status: 'pending' }); -await client.security.suggestedBindings.confirm(suggestions[0].id); +// A suggestion ROW is deliberately open (`Record`) — its column +// set belongs to the backing object, not the contract. Read the fields you know +// (`id`, `status`, `package_id`) and narrow them at the point of use. +await client.security.suggestedBindings.confirm(String(suggestions[0].id)); // Storage — File upload and management await client.storage.upload(fileData, 'user'); diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index 20f1f2b4f6..8d8f3ed70a 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -1351,7 +1351,12 @@ describe('ObjectStackClient.automation', () => { const result = await client.automation.resume('my_flow', 'run_1', { inputs: { account_id: 'a1' } }); expect(result.status).toBe('paused'); - expect(result.screen.nodeId).toBe('step2'); + // [#8140] `resume` now declares `AutomationResult`, on which `screen` + // is optional — a run that COMPLETED carries none. Asserting its + // presence before reading through it is the consumer-side half of that + // narrowing, and is exactly the migration an external caller makes. + expect(result.screen).toBeDefined(); + expect(result.screen?.nodeId).toBe('step2'); }); // [#8684] BREAKING: a run that resumed and then FAILED used to resolve with diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index cc5b84fb94..90edd61eda 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -83,8 +83,42 @@ import type { ApprovalActionRow, ApprovalStatus, ApprovalDecisionResult, + // [#8140] The service-contract return types this SDK relays verbatim. Each + // one is the DECLARED return of the service method the route calls, and the + // route serves it under at most one `{ success, data }` envelope that + // `unwrapResponse` strips — so the annotation on the method and the value + // the caller receives are one statement, not two. + ApprovalRecallResult, + ApprovalResubmitResult, + ApprovalSendBackResult, + AudienceBindingSuggestion, + AudienceBindingSuggestionSync, + AutomationResult, + DelegableScope, + ImportObjectResult, + ObjectDraft, + RecordShare, + RemoteTable, + ReportRunResult, + ReportSchedule, + SavedReport, + SchemaValidationReport, + ScreenSpec, + SendEmailResult, + ShareLink, + SharingRuleEvaluationResult, + SharingRuleRow, } from '@objectstack/spec/contracts'; -import type { ExecutionStatus } from '@objectstack/spec/automation'; +import type { + ActionDescriptor, + ExecutionLog, + ExecutionStatus, + FlowParsed, +} from '@objectstack/spec/automation'; +import type { ExternalCatalog } from '@objectstack/spec/data'; +import type { InstalledPackage } from '@objectstack/spec/kernel'; +import type { ConnectorDescriptor } from '@objectstack/spec/integration'; +import type { ExplainDecision } from '@objectstack/spec/security'; import type { InvitationStatus } from '@objectstack/spec/identity'; import { Logger, createLogger } from '@objectstack/core/logger'; import { RealtimeAPI } from './realtime-api'; @@ -1200,7 +1234,7 @@ export class ObjectStackClient { replyTo?: any; sentBy?: string; [k: string]: any; - }): Promise => { + }): Promise => { // [#6714] The base comes from `getRoute('email')`: a connected client // follows the server's advertised `routes.email` (the REST discovery // endpoint projects it from its recorded route registrations — the @@ -1213,7 +1247,7 @@ export class ObjectStackClient { method: 'POST', body: JSON.stringify(message), }); - return this.unwrapResponse(res); + return this.unwrapResponse(res); }, }; @@ -1234,56 +1268,56 @@ export class ObjectStackClient { datasources = { external: { /** List remote tables on a datasource, optionally by `schema`. */ - listTables: async (name: string, opts?: { schema?: string }): Promise => { + listTables: async (name: string, opts?: { schema?: string }): Promise<{ tables: RemoteTable[] }> => { const qs = opts?.schema ? `?schema=${encodeURIComponent(opts.schema)}` : ''; const route = this.getRoute('datasources'); const res = await this.fetch( `${this.baseUrl}${route}/${encodeURIComponent(name)}/external/tables${qs}`, ); - return this.unwrapResponse(res); + return this.unwrapResponse<{ tables: RemoteTable[] }>(res); }, /** Generate an Object draft (structured + `*.object.ts` source) from a remote table. */ - draft: async (name: string, remoteTable: string, opts?: Record): Promise => { + draft: async (name: string, remoteTable: string, opts?: Record): Promise<{ draft: ObjectDraft }> => { const route = this.getRoute('datasources'); const res = await this.fetch( `${this.baseUrl}${route}/${encodeURIComponent(name)}/external/tables/${encodeURIComponent(remoteTable)}/draft`, { method: 'POST', body: JSON.stringify(opts ?? {}) }, ); - return this.unwrapResponse(res); + return this.unwrapResponse<{ draft: ObjectDraft }>(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 => { + import: async (name: string, remoteTable: string, opts?: Record): Promise<{ object: ImportObjectResult }> => { const route = this.getRoute('datasources'); const res = await this.fetch( `${this.baseUrl}${route}/${encodeURIComponent(name)}/external/tables/${encodeURIComponent(remoteTable)}/import`, { method: 'POST', body: JSON.stringify(opts ?? {}) }, ); - return this.unwrapResponse(res); + return this.unwrapResponse<{ object: ImportObjectResult }>(res); }, /** Refresh and return the cached remote-catalog snapshot. */ - refreshCatalog: async (name: string): Promise => { + refreshCatalog: async (name: string): Promise<{ catalog: ExternalCatalog }> => { const route = this.getRoute('datasources'); const res = await this.fetch( `${this.baseUrl}${route}/${encodeURIComponent(name)}/external/refresh-catalog`, { method: 'POST', body: JSON.stringify({}) }, ); - return this.unwrapResponse(res); + return this.unwrapResponse<{ catalog: ExternalCatalog }>(res); }, /** Validate this datasource's federated objects against the remote schema. */ - validate: async (name: string): Promise => { + validate: async (name: string): Promise => { const route = this.getRoute('datasources'); const res = await this.fetch( `${this.baseUrl}${route}/${encodeURIComponent(name)}/external/validate`, { method: 'POST', body: JSON.stringify({}) }, ); - return this.unwrapResponse(res); + return this.unwrapResponse(res); }, }, }; @@ -3248,7 +3282,7 @@ export class ObjectStackClient { /** * Get a flow definition by name */ - get: async (name: string): Promise => { + get: async (name: string): Promise => { const route = this.getRoute('automation'); const res = await this.fetch(`${this.baseUrl}${route}/${name}`); return this.unwrapResponse(res); @@ -3256,6 +3290,14 @@ export class ObjectStackClient { /** * Create (register) a new flow + * + * [#8140] ⛔ `Promise` is DELIBERATE here, and it is a missing + * CONTRACT rather than a missing annotation. The route ends + * `deps.success(body)` — the request body, echoed — and + * `IAutomationService.registerFlow(name, definition: unknown): void` + * returns nothing, so no published type describes what comes back. + * Naming `Flow` would be a claim about the REQUEST that no validation + * backs. Authoring the response contract is `packages/spec`'s call. */ create: async (name: string, definition: any): Promise => { const route = this.getRoute('automation'); @@ -3268,6 +3310,10 @@ export class ObjectStackClient { /** * Update an existing flow + * + * [#8140] ⛔ `Promise` is DELIBERATE — same missing contract as + * `create` above: the route ends `deps.success(definition)`, echoing + * what was sent. */ update: async (name: string, definition: any): Promise => { const route = this.getRoute('automation'); @@ -3299,7 +3345,7 @@ export class ObjectStackClient { * ADR-0018: registered action-node descriptors, optionally filtered by * `paradigm` / `source` / `category`. Empty registry → `{ actions: [], total: 0 }`. */ - listActions: async (opts?: { paradigm?: string; source?: string; category?: string }): Promise<{ actions: any[]; total: number }> => { + listActions: async (opts?: { paradigm?: string; source?: string; category?: string }): Promise<{ actions: ActionDescriptor[]; total: number }> => { const route = this.getRoute('automation'); const params = new URLSearchParams(); if (opts?.paradigm) params.set('paradigm', opts.paradigm); @@ -3314,7 +3360,7 @@ export class ObjectStackClient { * ADR-0022: registered connector descriptors (populated by connector * plugins), optionally filtered by `type`. */ - listConnectors: async (opts?: { type?: string }): Promise<{ connectors: any[]; total: number }> => { + listConnectors: async (opts?: { type?: string }): Promise<{ connectors: ConnectorDescriptor[]; total: number }> => { const route = this.getRoute('automation'); const qs = opts?.type ? `?type=${encodeURIComponent(opts.type)}` : ''; const res = await this.fetch(`${this.baseUrl}${route}/connectors${qs}`); @@ -3347,7 +3393,7 @@ export class ObjectStackClient { /** * List execution runs for a flow */ - list: async (flowName: string, options?: { limit?: number; cursor?: string }): Promise<{ runs: any[]; hasMore: boolean }> => { + list: async (flowName: string, options?: { limit?: number; cursor?: string }): Promise<{ runs: ExecutionLog[]; hasMore: boolean }> => { const route = this.getRoute('automation'); const params = new URLSearchParams(); if (options?.limit) params.set('limit', String(options.limit)); @@ -3360,7 +3406,7 @@ export class ObjectStackClient { /** * Get a single execution run */ - get: async (flowName: string, runId: string): Promise => { + get: async (flowName: string, runId: string): Promise => { const route = this.getRoute('automation'); const res = await this.fetch(`${this.baseUrl}${route}/${flowName}/runs/${runId}`); return this.unwrapResponse(res); @@ -3371,9 +3417,28 @@ export class ObjectStackClient { * Flat aliases mirroring the ScopedProjectClient.automation surface so * Studio (and other consumers) can use the same call shape regardless of * whether they hold a scoped or unscoped client. + * + * [#8140] These six are FIXED-SHAPE platform methods, so their type + * parameter is `` rather than the `` it used + * to be. Both halves of that spelling are load-bearing: + * + * - the DEFAULT closes the erasure for the ordinary call + * (`await getFlow(n)` was `any`, and is now `FlowParsed`); + * - the CONSTRAINT closes it for the annotated call. A bare + * `` is only half a fix — TypeScript infers `T` from + * the call's contextual type, so `const x: SomethingElse = await + * getFlow(n)` still compiled and `T` silently became `SomethingElse`. + * Measured on this card's own pin file, which is why the constraint + * is here. + * + * A caller narrowing to their own known shape keeps working + * (`getFlow(…)`); one naming an + * unrelated type is now refused, which is the point. The + * caller-supplied generics on `data.*` and `actions.*` are deliberately + * NOT constrained — there the payload really is the caller's. */ /** Alias for `automation.get` — fetch a flow definition by name. */ - getFlow: async (name: string): Promise => { + getFlow: async (name: string): Promise => { const route = this.getRoute('automation'); const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(name)}`); return this.unwrapResponse(res) as Promise; @@ -3390,7 +3455,7 @@ export class ObjectStackClient { * both never dispatched, so neither is `FLOW_FAILED`. * See `automation.trigger` for the full table — both call the same door. */ - execute: async (name: string, ctx?: Record): Promise => { + execute: async (name: string, ctx?: Record): Promise => { const route = this.getRoute('automation'); const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(name)}/trigger`, { method: 'POST', @@ -3399,7 +3464,7 @@ export class ObjectStackClient { return this.unwrapResponse(res) as Promise; }, /** Alias for `automation.runs.list`. */ - listRuns: async ( + listRuns: async ( flowName: string, opts?: { limit?: number; cursor?: string; status?: ExecutionStatus }, ): Promise => { @@ -3419,7 +3484,7 @@ export class ObjectStackClient { return this.unwrapResponse(res) as Promise; }, /** Alias for `automation.runs.get`. */ - getRun: async (flowName: string, runId: string): Promise => { + getRun: async (flowName: string, runId: string): Promise => { const route = this.getRoute('automation'); const res = await this.fetch( `${this.baseUrl}${route}/${encodeURIComponent(flowName)}/runs/${encodeURIComponent(runId)}`, @@ -3470,7 +3535,7 @@ export class ObjectStackClient { * `INVALID_SCREEN_INPUT` (400), `RESUME_IN_PROGRESS` (409), * `STORE_UNAVAILABLE` (503). */ - resume: async ( + resume: async ( flowName: string, runId: string, signal?: { @@ -3494,7 +3559,7 @@ export class ObjectStackClient { * not launch the run (a reload, a different tab, an inbox) render the * pending step before calling {@link resume}. */ - getScreen: async (flowName: string, runId: string): Promise => { + getScreen: async (flowName: string, runId: string): Promise => { const route = this.getRoute('automation'); const res = await this.fetch( `${this.baseUrl}${route}/${encodeURIComponent(flowName)}/runs/${encodeURIComponent(runId)}/screen`, @@ -3535,6 +3600,13 @@ export class ObjectStackClient { * Invoke a server-registered action on an object. * Falls back to the server's object-less ('global') handler when no * object-specific handler is registered. + * + * [#8140] `` STAYS. The envelope is already precise + * (`{ success, data?, error? }`); `T` is the return value of the + * app-author's own handler registered with + * `engine.registerAction(objectName, actionName, handler)`, so it is + * caller-supplied in exactly the sense `data.get` is — not the + * fixed-shape platform erasure this card was about. */ invoke: async ( objectName: string, @@ -3632,7 +3704,7 @@ export class ObjectStackClient { redactFields?: string[]; label?: string; }, - ): Promise => { + ): Promise => { const res = await this.fetch(`${this.baseUrl}/api/v1/share-links`, { method: 'POST', body: JSON.stringify({ object, recordId, ...(opts ?? {}) }), @@ -3645,7 +3717,7 @@ export class ObjectStackClient { object?: string; recordId?: string; includeRevoked?: boolean; - }): Promise => { + }): Promise => { const params = new URLSearchParams(); if (opts?.object) params.set('object', opts.object); if (opts?.recordId) params.set('recordId', opts.recordId); @@ -3698,12 +3770,12 @@ export class ObjectStackClient { userId?: string; recordId?: string; recordIds?: string[]; - }): Promise => { + }): Promise => { const res = await this.fetch(`${this.baseUrl}/api/v1/security/explain`, { method: 'POST', body: JSON.stringify(request), }); - return this.unwrapResponse(res); + return this.unwrapResponse(res); }, /** @@ -3719,14 +3791,14 @@ export class ObjectStackClient { * discloses nothing beyond the caller's own authority; a tenant admin * comes back `isTenantAdmin: true` with everything enumerated. */ - describeDelegableScope: async (): Promise => { + describeDelegableScope: async (): Promise => { const res = await this.fetch(`${this.baseUrl}/api/v1/security/my-delegable-scope`); - return this.unwrapResponse(res); + return this.unwrapResponse(res); }, suggestedBindings: { /** List suggestions, optionally by `status` / `packageId` (reconciles first). */ - list: async (opts?: { status?: string; packageId?: string }): Promise => { + list: async (opts?: { status?: string; packageId?: string }): Promise<{ suggestions: AudienceBindingSuggestion[]; synced: AudienceBindingSuggestionSync }> => { const params = new URLSearchParams(); if (opts?.status) params.set('status', opts.status); if (opts?.packageId) params.set('packageId', opts.packageId); @@ -3738,7 +3810,7 @@ export class ObjectStackClient { }, /** Confirm a suggestion — creates the anchor binding. */ - confirm: async (id: string): Promise => { + confirm: async (id: string): Promise<{ suggestion: AudienceBindingSuggestion; bindingCreated: boolean }> => { const res = await this.fetch( `${this.baseUrl}/api/v1/security/suggested-bindings/${encodeURIComponent(id)}/confirm`, { method: 'POST' }, @@ -3747,7 +3819,7 @@ export class ObjectStackClient { }, /** Dismiss (decline) a suggestion. */ - dismiss: async (id: string): Promise => { + dismiss: async (id: string): Promise<{ suggestion: AudienceBindingSuggestion }> => { const res = await this.fetch( `${this.baseUrl}/api/v1/security/suggested-bindings/${encodeURIComponent(id)}/dismiss`, { method: 'POST' }, @@ -3869,69 +3941,69 @@ export class ObjectStackClient { * Recall (withdraw) a pending request. Submitter-only — the service * enforces access. (#3587 gap closure) */ - recall: async (requestId: string, opts?: { actorId?: string; comment?: string }): Promise => { + recall: async (requestId: string, opts?: { actorId?: string; comment?: string }): Promise => { const route = this.getRoute('approvals'); const res = await this.fetch(`${this.baseUrl}${route}/requests/${encodeURIComponent(requestId)}/recall`, { method: 'POST', body: JSON.stringify({ actorId: opts?.actorId, comment: opts?.comment }), }); - return this.unwrapResponse(res); + return this.unwrapResponse(res); }, /** * ADR-0044 send-back-for-revision: the request finalizes `returned` and * the flow run parks at a wait point. Pending-approver-only. */ - revise: async (requestId: string, opts?: { actorId?: string; comment?: string }): Promise => { + revise: async (requestId: string, opts?: { actorId?: string; comment?: string }): Promise => { const route = this.getRoute('approvals'); const res = await this.fetch(`${this.baseUrl}${route}/requests/${encodeURIComponent(requestId)}/revise`, { method: 'POST', body: JSON.stringify({ actorId: opts?.actorId, comment: opts?.comment }), }); - return this.unwrapResponse(res); + return this.unwrapResponse(res); }, /** * ADR-0044 resubmit-after-revision: re-enters the approval node. * Submitter-only. Returns the flow outcome (`resumed` / `autoRejected`). */ - resubmit: async (requestId: string, opts?: { actorId?: string; comment?: string }): Promise => { + resubmit: async (requestId: string, opts?: { actorId?: string; comment?: string }): Promise => { const route = this.getRoute('approvals'); const res = await this.fetch(`${this.baseUrl}${route}/requests/${encodeURIComponent(requestId)}/resubmit`, { method: 'POST', body: JSON.stringify({ actorId: opts?.actorId, comment: opts?.comment }), }); - return this.unwrapResponse(res); + return this.unwrapResponse(res); }, /** Nudge the pending approver(s); a thread interaction — the flow does not move. */ - remind: async (requestId: string, opts?: { actorId?: string; comment?: string }): Promise => { + remind: async (requestId: string, opts?: { actorId?: string; comment?: string }): Promise<{ request: ApprovalRequestRow; notified: number }> => { const route = this.getRoute('approvals'); const res = await this.fetch(`${this.baseUrl}${route}/requests/${encodeURIComponent(requestId)}/remind`, { method: 'POST', body: JSON.stringify({ actorId: opts?.actorId, comment: opts?.comment }), }); - return this.unwrapResponse(res); + return this.unwrapResponse<{ request: ApprovalRequestRow; notified: number }>(res); }, /** Ask the submitter for more information (thread interaction). */ - requestInfo: async (requestId: string, opts?: { actorId?: string; comment?: string }): Promise => { + requestInfo: async (requestId: string, opts?: { actorId?: string; comment?: string }): Promise<{ request: ApprovalRequestRow }> => { const route = this.getRoute('approvals'); const res = await this.fetch(`${this.baseUrl}${route}/requests/${encodeURIComponent(requestId)}/request-info`, { method: 'POST', body: JSON.stringify({ actorId: opts?.actorId, comment: opts?.comment }), }); - return this.unwrapResponse(res); + return this.unwrapResponse<{ request: ApprovalRequestRow }>(res); }, /** Append a comment (optionally with attachments) to the request thread. */ - comment: async (requestId: string, opts: { comment: string; actorId?: string; attachments?: string[] }): Promise => { + comment: async (requestId: string, opts: { comment: string; actorId?: string; attachments?: string[] }): Promise<{ request: ApprovalRequestRow }> => { const route = this.getRoute('approvals'); const res = await this.fetch(`${this.baseUrl}${route}/requests/${encodeURIComponent(requestId)}/comment`, { method: 'POST', body: JSON.stringify({ actorId: opts.actorId, comment: opts.comment, attachments: opts.attachments }), }); - return this.unwrapResponse(res); + return this.unwrapResponse<{ request: ApprovalRequestRow }>(res); }, /** @@ -3954,12 +4026,12 @@ export class ObjectStackClient { */ shares = { /** List the sharing grants on a record. */ - list: async (object: string, recordId: string): Promise => { + list: async (object: string, recordId: string): Promise => { const route = this.getRoute('data'); const res = await this.fetch( `${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/shares`, ); - const body = await this.unwrapResponse<{ data?: any[] } | any[]>(res); + const body = await this.unwrapResponse<{ data?: RecordShare[] } | RecordShare[]>(res); return Array.isArray(body) ? body : (body?.data ?? []); }, @@ -3978,13 +4050,13 @@ export class ObjectStackClient { sourceId?: string; reason?: string; }, - ): Promise => { + ): Promise => { const route = this.getRoute('data'); const res = await this.fetch( `${this.baseUrl}${route}/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}/shares`, { method: 'POST', body: JSON.stringify(opts) }, ); - return this.unwrapResponse(res); + return this.unwrapResponse(res); }, /** Revoke a share by its id. */ @@ -4005,13 +4077,13 @@ export class ObjectStackClient { */ rules: { /** List sharing rules, optionally by object / active-only. */ - list: async (opts?: { object?: string; activeOnly?: boolean }): Promise => { + 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); + const body = await this.unwrapResponse<{ data?: SharingRuleRow[] } | SharingRuleRow[]>(res); return Array.isArray(body) ? body : (body?.data ?? []); }, @@ -4026,18 +4098,18 @@ export class ObjectStackClient { label?: string; description?: string; active?: boolean; - }): Promise => { + }): Promise => { const res = await this.fetch(`${this.baseUrl}/api/v1/sharing/rules`, { method: 'POST', body: JSON.stringify(rule), }); - return this.unwrapResponse(res); + return this.unwrapResponse(res); }, /** Get a sharing rule by id or name. 404 [RULE_NOT_FOUND] when absent. */ - get: async (idOrName: string): Promise => { + get: async (idOrName: string): Promise => { const res = await this.fetch(`${this.baseUrl}/api/v1/sharing/rules/${encodeURIComponent(idOrName)}`); - return this.unwrapResponse(res); + return this.unwrapResponse(res); }, /** Delete a sharing rule; its materialised grants cascade. */ @@ -4050,12 +4122,12 @@ export class ObjectStackClient { }, /** Re-evaluate a rule against current data and reconcile its grants. */ - evaluate: async (idOrName: string): Promise => { + 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); + return this.unwrapResponse(res); }, }, }; @@ -4064,6 +4136,17 @@ export class ObjectStackClient { * Global cross-object search (M10.5): one query across every searchable * object the caller can read. 501s on kernels without `searchAll`. * (#3587 gap closure) + * + * [#8140] ⛔ `Promise` is DELIBERATE — a missing CONTRACT, not a + * missing annotation. The response shape + * (`{ query, hits, totalObjects, totalHits, truncated }`) is declared + * INLINE on the implementation (`@objectstack/metadata-protocol`'s + * `searchAll`), not in `@objectstack/spec`, and `metadata-protocol` is not + * a dependency of this package — so there is nothing reachable to bind. + * ⚠️ `SearchResult` (`@objectstack/spec/contracts`) is a NEAR-MISS trap: it + * types the per-object `ISearchService.search`, whose `hits` carry + * `score`/`document`, not this route's `object`/`title`/`snippet`/`record`. + * Binding it would typecheck and be false. */ search = async ( q: string, @@ -4088,29 +4171,29 @@ export class ObjectStackClient { */ reports = { /** List saved reports, optionally filtered by object or owner. */ - list: async (opts?: { object?: string; ownerId?: string }): Promise => { + list: async (opts?: { object?: string; ownerId?: string }): Promise => { const params = new URLSearchParams(); if (opts?.object) params.set('object', opts.object); if (opts?.ownerId) params.set('ownerId', opts.ownerId); const qs = params.toString(); const res = await this.fetch(`${this.baseUrl}/api/v1/reports${qs ? `?${qs}` : ''}`); - const body = await this.unwrapResponse<{ data?: any[] } | any[]>(res); + const body = await this.unwrapResponse<{ data?: SavedReport[] } | SavedReport[]>(res); return Array.isArray(body) ? body : (body?.data ?? []); }, /** Create or update a saved report definition. 400 [VALIDATION_FAILED] on a bad spec. */ - save: async (report: any): Promise => { + save: async (report: any): Promise => { const res = await this.fetch(`${this.baseUrl}/api/v1/reports`, { method: 'POST', body: JSON.stringify(report ?? {}), }); - return this.unwrapResponse(res); + return this.unwrapResponse(res); }, /** Get a saved report by id. 404 [REPORT_NOT_FOUND] when absent. */ - get: async (id: string): Promise => { + get: async (id: string): Promise => { const res = await this.fetch(`${this.baseUrl}/api/v1/reports/${encodeURIComponent(id)}`); - return this.unwrapResponse(res); + return this.unwrapResponse(res); }, /** Delete a saved report; its schedules cascade. */ @@ -4123,12 +4206,12 @@ export class ObjectStackClient { }, /** Execute a saved report and return its rendered output. */ - run: async (id: string): Promise => { + run: async (id: string): Promise => { const res = await this.fetch(`${this.baseUrl}/api/v1/reports/${encodeURIComponent(id)}/run`, { method: 'POST', body: JSON.stringify({}), }); - return this.unwrapResponse(res); + return this.unwrapResponse(res); }, /** @@ -4148,18 +4231,18 @@ export class ObjectStackClient { ownerId?: string; active?: boolean; }, - ): Promise => { + ): Promise => { const res = await this.fetch(`${this.baseUrl}/api/v1/reports/${encodeURIComponent(id)}/schedule`, { method: 'POST', body: JSON.stringify(opts), }); - return this.unwrapResponse(res); + return this.unwrapResponse(res); }, /** List the recurring schedules attached to a report. */ - listSchedules: async (id: string): Promise => { + listSchedules: async (id: string): Promise => { const res = await this.fetch(`${this.baseUrl}/api/v1/reports/${encodeURIComponent(id)}/schedules`); - const body = await this.unwrapResponse<{ data?: any[] } | any[]>(res); + const body = await this.unwrapResponse<{ data?: ReportSchedule[] } | ReportSchedule[]>(res); return Array.isArray(body) ? body : (body?.data ?? []); }, @@ -4951,6 +5034,13 @@ export class ObjectStackClient { * 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) + * + * [#8140] ⛔ `Promise` is DELIBERATE — a missing CONTRACT. The route + * returns `{ object, id, sourceId, record }`, produced inline by + * `@objectstack/metadata-protocol`'s `cloneData` and declared nowhere in + * `@objectstack/spec`. It is the structural sibling of this file's own + * `CreateDataResult` plus `sourceId`, but writing that equivalence + * here would mint an undeclared contract in a consumer. */ clone: async (object: string, id: string, overrides?: Record): Promise => { const route = this.getRoute('data'); @@ -5568,9 +5658,9 @@ export class ScopedProjectClient { * package tests. */ packages = { - list: async (): Promise<{ packages: any[]; total: number }> => { + list: async (): Promise<{ packages: InstalledPackage[]; total: number }> => { const res = await this.parent._fetch(this.url('/packages')); - return this.parent._unwrap<{ packages: any[]; total: number }>(res); + return this.parent._unwrap<{ packages: InstalledPackage[]; total: number }>(res); }, get: async (id: string, version?: string) => { const qs = version ? `?version=${encodeURIComponent(version)}` : ''; @@ -5589,7 +5679,7 @@ export class ScopedProjectClient { */ automation = { /** Fetch a flow definition by name. */ - getFlow: async (name: string): Promise => { + getFlow: async (name: string): Promise => { const res = await this.parent._fetch(this.url(`/automation/${encodeURIComponent(name)}`)); return this.parent._unwrap(res); }, @@ -5605,7 +5695,7 @@ export class ScopedProjectClient { * `409` `FLOW_DISABLED` and a definition with no `start` node with `422` * `FLOW_NO_START_NODE`. See that method for the full table. */ - execute: async (name: string, ctx?: Record): Promise => { + execute: async (name: string, ctx?: Record): Promise => { const res = await this.parent._fetch(this.url(`/automation/${encodeURIComponent(name)}/trigger`), { method: 'POST', body: JSON.stringify(ctx ?? {}), @@ -5613,7 +5703,7 @@ export class ScopedProjectClient { return this.parent._unwrap(res); }, /** List recent runs for a flow, optionally narrowed to one status. */ - listRuns: async ( + listRuns: async ( flowName: string, opts?: { limit?: number; cursor?: string; status?: ExecutionStatus }, ): Promise => { @@ -5629,7 +5719,7 @@ export class ScopedProjectClient { return this.parent._unwrap(res); }, /** Fetch a single run (with step log) for a flow. */ - getRun: async (flowName: string, runId: string): Promise => { + getRun: async (flowName: string, runId: string): Promise => { const res = await this.parent._fetch( this.url(`/automation/${encodeURIComponent(flowName)}/runs/${encodeURIComponent(runId)}`), ); @@ -5645,7 +5735,7 @@ export class ScopedProjectClient { * `err.details.errorMessage`) instead of resolving with an inner * `{ success: false }` under HTTP 200. See that method for the full shape. */ - resume: async ( + resume: async ( flowName: string, runId: string, signal?: { @@ -5661,7 +5751,7 @@ export class ScopedProjectClient { return this.parent._unwrap(res); }, /** Fetch the screen a paused run is waiting on. */ - getScreen: async (flowName: string, runId: string): Promise => { + getScreen: async (flowName: string, runId: string): Promise => { const res = await this.parent._fetch( this.url(`/automation/${encodeURIComponent(flowName)}/runs/${encodeURIComponent(runId)}/screen`), ); diff --git a/packages/client/src/return-type-precision.test.ts b/packages/client/src/return-type-precision.test.ts new file mode 100644 index 0000000000..db2a7a6320 --- /dev/null +++ b/packages/client/src/return-type-precision.test.ts @@ -0,0 +1,224 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#8140] The SDK boundary must not erase the spec types it already depends on. + * + * ## What can and cannot pin a return-type narrowing + * + * These pins are **type-level on purpose**, and the distinction is load-bearing + * rather than stylistic. A runtime test — call the method against a stubbed + * transport, assert on the value — stays GREEN against a client that still + * declares `Promise`, because the value is identical either way. Only the + * DECLARED type changed, so only a compile-time assertion can observe it. + * + * They are compiled: `packages/client/tsconfig.test.json` includes `src/**\/*` + * and `package.json`'s `typecheck` script names it through + * `check:test-typecheck`. A pin in a file no tsc program reads is a phantom + * check (AGENTS.md, "Build & Test") — this one is read by the same gate that + * holds every other file in this package at zero errors. + * + * ## Two independent failure modes, both red without the change + * + * 1. `expectTypeOf(...).toEqualTypeOf()` — `any` is not equal to `X` under + * vitest's branded equality, so each of these errors while the method still + * returns `any`. + * 2. `@ts-expect-error` on an assignment to a deliberately WRONG shape — `any` + * is assignable to everything, so the suppression goes unused and tsc + * reports TS2578 ("Unused '@ts-expect-error' directive"). This is the + * direction that catches a "narrowing" to something still permissive. + * + * The one guard below that is green in BOTH states is labelled as such; it + * pins a near-miss trap in the spec rather than this package's annotations. + */ + +import { describe, it, expect, expectTypeOf, vi } from 'vitest'; +import { ObjectStackClient, ScopedProjectClient } from './index'; +import type { + AutomationResult, + DelegableScope, + ImportObjectResult, + RecordShare, + RemoteTable, + ReportSchedule, + SavedReport, + SearchResult, + ShareLink, + SharingRuleRow, +} from '@objectstack/spec/contracts'; +import type { ActionDescriptor, ExecutionLog, FlowParsed } from '@objectstack/spec/automation'; +import type { ExplainDecision } from '@objectstack/spec/security'; +import type { InstalledPackage } from '@objectstack/spec/kernel'; + +declare const client: ObjectStackClient; +declare const scoped: ScopedProjectClient; + +/** + * Compiled, never invoked. Every statement is an assertion tsc evaluates; none + * of them may perform a request, which is why this is not an `it()` body. + */ +export async function returnTypePrecisionPins(): Promise { + // ── shape class 1: the contract type, served bare ──────────────────── + // `res.json(await svc.describeDelegableScope(context))` — no envelope. + expectTypeOf(await client.security.describeDelegableScope()).toEqualTypeOf(); + + // ── shape class 2: contract type inside a route-built ENVELOPE ─────── + // The census's highest-risk band: `sendOk(res, { tables })` means the + // caller holds `{ tables: RemoteTable[] }`, NOT `RemoteTable[]`. Binding + // the obvious-but-wrong `RemoteTable[]` would typecheck against `any` and + // ship a false declaration — this pin is what makes that a compile error. + expectTypeOf(await client.datasources.external.listTables('ds')).toEqualTypeOf<{ + tables: RemoteTable[]; + }>(); + expectTypeOf(await client.datasources.external.import('ds', 'people')).toEqualTypeOf<{ + object: ImportObjectResult; + }>(); + + // ── shape class 3: array ELEMENT typed through the client's own unwrap ─ + // These routes answer `{ data: rows }` with no `success` flag, so + // `unwrapResponse` passes it through and the method folds it to an array. + expectTypeOf(await client.reports.list()).toEqualTypeOf(); + expectTypeOf(await client.reports.listSchedules('rep_1')).toEqualTypeOf(); + expectTypeOf(await client.shares.list('lead', 'rec_1')).toEqualTypeOf(); + expectTypeOf(await client.shares.rules.list()).toEqualTypeOf(); + expectTypeOf(await client.shareLinks.list()).toEqualTypeOf(); + + // ── shape class 4: PARTIAL erasure — precise envelope, `any[]` member ── + expectTypeOf(await client.automation.listActions()).toEqualTypeOf<{ + actions: ActionDescriptor[]; + total: number; + }>(); + expectTypeOf(await client.automation.runs.list('flow_a')).toEqualTypeOf<{ + runs: ExecutionLog[]; + hasMore: boolean; + }>(); + + // ── shape class 5: the `` DEFAULT on a fixed-shape method ─────── + // Called with no type argument, so the default is what is under test. The + // caller-supplied `data.*` and `actions.*` generics are deliberately NOT + // here — see this card's report for why they stay `T = any`. + expectTypeOf(await client.automation.getFlow('flow_a')).toEqualTypeOf(); + expectTypeOf(await client.automation.execute('flow_a')).toEqualTypeOf(); + expectTypeOf(await client.automation.getRun('flow_a', 'run_1')).toEqualTypeOf(); + + // Explicit type arguments still work for a LEGITIMATE narrowing — the + // parameter was kept, its default moved off `any`, and a constraint was + // added. This is the compatibility half of the narrowing. + expectTypeOf( + await client.automation.getFlow('flow_a'), + ).toEqualTypeOf(); + + // …and an UNRELATED type is now refused. Without the constraint TypeScript + // infers `T` from the contextual type and this compiles — which is how the + // `` spelling was measured to be only half a fix. + // @ts-expect-error ExecutionLog does not satisfy `T extends FlowParsed` + await client.automation.getFlow('flow_a'); + + // ── shape class 6: the ScopedProjectClient MIRROR carries the same types ─ + expectTypeOf(await scoped.automation.getFlow('flow_a')).toEqualTypeOf(); + expectTypeOf(await scoped.automation.getRun('flow_a', 'run_1')).toEqualTypeOf(); + expectTypeOf(await scoped.packages.list()).toEqualTypeOf<{ + packages: InstalledPackage[]; + total: number; + }>(); + + // ── shape class 7: z.input vs z.infer, decided by the CONTRACT ───────── + // `ISecurityService.explain` declares `Promise` (the + // `z.input` form) and the route relays it with `res.json(decision)` — no + // parse step anywhere on the path. Binding the post-parse + // `ExplainDecisionParsed` would assert more than the contract guarantees. + expectTypeOf(await client.security.explain({ object: 'lead' })).toEqualTypeOf(); + + // ── direction 2: a WRONG shape must now be rejected ─────────────────── + // Each suppression below is unused — and therefore a TS2578 error — while + // the method still returns `any`. + + // @ts-expect-error a DelegableScope is not a string + const wrongScope: string = await client.security.describeDelegableScope(); + + // @ts-expect-error the route answers `{ tables }`, not a bare array + const wrongTables: RemoteTable[] = await client.datasources.external.listTables('ds'); + + // @ts-expect-error `reports.list` answers SavedReport[], not a single row + const wrongReport: SavedReport = await client.reports.list(); + + // @ts-expect-error a flow definition is not an execution log + const wrongFlow: ExecutionLog = await client.automation.getFlow('flow_a'); + + void wrongScope; + void wrongTables; + void wrongReport; + void wrongFlow; +} + +/** + * ⚠️ GREEN IN BOTH STATES, and recorded as such rather than padded into the + * red list above. This pins a trap in `@objectstack/spec`, not an annotation + * in this file: `SearchResult` sits one import away from `client.search` and + * is the WRONG type for it — it contracts the per-object + * `ISearchService.search`, whose hits carry `score` / `document`, while the + * cross-object route answers hits of `object` / `id` / `title` / `snippet` / + * `record`. Binding it would compile and be false. `client.search` therefore + * stays `Promise` deliberately (a missing contract, not a missing + * annotation) and this guard exists so the next sweep does not "finish" the + * card by reaching for the same-named neighbour. + */ +type GlobalSearchHit = { + object: string; + id: string; + title: string; + snippet?: string; + record: unknown; +}; +declare const searchHit: SearchResult['hits'][number]; + +export function searchResultIsNotTheGlobalSearchShape(): void { + // @ts-expect-error `SearchHit` (score/document) is not the global-search hit + const mismatched: GlobalSearchHit = searchHit; + void mismatched; +} + +describe('client SDK return-type precision (#8140)', () => { + it('exposes the type-level pins to tsc without executing a request', () => { + // The assertions above are evaluated by `tsc` under + // `tsconfig.test.json`, not by this runtime. This case exists so the + // file is a test file and the functions are referenced; it is NOT the + // pin, and it cannot be — a runtime call cannot observe a return-type + // narrowing at all. + expect(typeof returnTypePrecisionPins).toBe('function'); + expect(typeof searchResultIsNotTheGlobalSearchShape).toBe('function'); + }); + + it('unwraps exactly one `{ success, data }` envelope — the premise the annotations rest on', async () => { + // Runtime-observable and deliberately so: every annotation added by + // this card describes the POST-unwrap value, so if `unwrapResponse` + // ever stripped two envelopes (or none) the declarations would become + // false without a single type error. This guard is green before and + // after the change — it protects the premise, not the narrowing. + const enveloped = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ success: true, data: { isTenantAdmin: true, scopes: [], placeableBusinessUnitIds: [], assignablePositions: [] } }), + headers: new Headers(), + }); + const c1 = new ObjectStackClient({ baseUrl: 'http://localhost:3000', fetch: enveloped }); + expect(await c1.security.describeDelegableScope()).toEqual({ + isTenantAdmin: true, + scopes: [], + placeableBusinessUnitIds: [], + assignablePositions: [], + }); + + // A bare body (no `success` flag) is passed through untouched — this is + // the arm `security.explain`, `approvals.*` and the report routes take. + const bare = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ isTenantAdmin: false, scopes: [], placeableBusinessUnitIds: ['bu_1'], assignablePositions: [] }), + headers: new Headers(), + }); + const c2 = new ObjectStackClient({ baseUrl: 'http://localhost:3000', fetch: bare }); + expect((await c2.security.describeDelegableScope()).placeableBusinessUnitIds).toEqual(['bu_1']); + }); +});