diff --git a/.changeset/23859-client-runtime-status-type.md b/.changeset/23859-client-runtime-status-type.md new file mode 100644 index 00000000000..368f0b3256d --- /dev/null +++ b/.changeset/23859-client-runtime-status-type.md @@ -0,0 +1,11 @@ +--- +'@objectstack/client': patch +--- + +Clause-②: no + +fix(client): `client.automation.getRuntimeStatus()` types its rows as the declared contract `FlowRuntimeState` (#23859) + +**What was wrong.** The method declared its answer as `{ flows: Array<{ name, enabled, bound }>; total }`. `GET /automation/_status` serves the automation engine's `getFlowRuntimeStates()` rows unchanged, and each row is the contract `FlowRuntimeState` from `@objectstack/spec/contracts`. The SDK's type therefore hid five optional fields the wire already carries: `status`, `triggerType`, `object`, `reason` (one sentence saying why an enabled, triggered flow is not armed) and `unboundCause` (which of the closed `FLOW_UNBOUND_CAUSES` that sentence describes). A caller that read `row.reason` or `row.unboundCause` got a type error and either cast past it or concluded the field did not exist. + +**What changes.** The return type is now `Promise<{ flows: FlowRuntimeState[]; total: number }>`. `name`, `enabled` and `bound` stay required, so existing callers compile unchanged. Read `row.unboundCause` to classify an unarmed flow, and render `row.reason` without parsing it. The request, the route and the values on the wire are unchanged. diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 6fcd25dbf4d..9e5f5301958 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -163,6 +163,7 @@ import type { AudienceBindingSuggestionSync, AutomationResult, DelegableScope, + FlowRuntimeState, ImportObjectResult, ObjectDraft, RecordShare, @@ -5664,8 +5665,13 @@ export class ObjectStackClient { /** * Runtime enable/bound state for every flow — engine state, not * persisted metadata (backs the Studio's Automations status badges). + * + * [#23859] Each row is the declared contract `FlowRuntimeState`: the + * route serves `getFlowRuntimeStates()` rows unchanged, so the optional + * `status` / `triggerType` / `object` / `reason` / `unboundCause` reach + * the caller. A hand-typed `{ name, enabled, bound }` hid them. */ - getRuntimeStatus: async (): Promise<{ flows: Array<{ name: string; enabled: boolean; bound: boolean }>; total: number }> => { + getRuntimeStatus: async (): Promise<{ flows: FlowRuntimeState[]; total: number }> => { const route = this.getRoute('automation'); const res = await this.fetch(`${this.baseUrl}${route}/_status`); return this.unwrapResponse(res); diff --git a/packages/client/src/return-type-precision.test.ts b/packages/client/src/return-type-precision.test.ts index a7eec8aef45..dae16a6812b 100644 --- a/packages/client/src/return-type-precision.test.ts +++ b/packages/client/src/return-type-precision.test.ts @@ -84,6 +84,8 @@ import type { AnalyticsResult, AutomationResult, DelegableScope, + FlowRuntimeState, + FlowUnboundCause, ImportObjectResult, RecordShare, RemoteTable, @@ -1418,6 +1420,45 @@ export function metaResetResponseDeclaresTheWireReceipt(): void { void metaResetBody.projectionApplied; } +/** + * [#23859] `automation.getRuntimeStatus()` hand-typed its rows as + * `{ name, enabled, bound }`. `GET /automation/_status` serves the engine's + * `getFlowRuntimeStates()` rows unchanged (that pass-through is pinned in + * `packages/runtime/src/domain-handler-registry.test.ts`), and those rows are + * the declared contract `FlowRuntimeState`. The narrower annotation hid + * `status`, `triggerType`, `object`, `reason` and `unboundCause` from every SDK + * caller, who then cast past it or concluded the fields did not exist. + * + * Type-level for the reason this file's header gives: the runtime pin in + * `meta-automation-descriptors.test.ts` is green against either annotation. + */ +type RuntimeStatusRow = Awaited>['flows'][number]; + +export async function returnTypePrecisionPins23859(): Promise { + // ── the answer IS the contract, envelope and element ────────────────── + // RED BEFORE: the hand-typed element is not `FlowRuntimeState`. + expectTypeOf(await client.automation.getRuntimeStatus()).toEqualTypeOf<{ + flows: FlowRuntimeState[]; + total: number; + }>(); + + // ── the optional fields a caller can now read, with the contract's own + // optionality and vocabulary ────────────────────────────────────── + // RED BEFORE: each indexed access is TS2339 on the hand-typed row. + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + + // ⚠️ GREEN IN BOTH STATES, a compatibility guard rather than evidence: the + // three fields the old annotation named stay REQUIRED, so a caller that + // read them still compiles. + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); +} + 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 @@ -1439,6 +1480,7 @@ describe('client SDK return-type precision (#8140)', () => { expect(typeof returnTypePrecisionPins13023).toBe('function'); expect(typeof deleteDataResponseIsNotTheMetaResetShape).toBe('function'); expect(typeof metaResetResponseDeclaresTheWireReceipt).toBe('function'); + expect(typeof returnTypePrecisionPins23859).toBe('function'); expect(typeof commitRollbackResponseIsNotTheVersionRollbackShape).toBe('function'); // `environmentIsNotTheCloudWireRow` left with its subject (#16325): the // guard is now the `@ts-expect-error`'d import at the top of the file.