From 37957fa9367136f5ecdf08c047cd92008cde218b Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 30 Jul 2026 03:05:30 +0200 Subject: [PATCH 1/3] chore: adopt @unthrown/oxlint recommended rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register the @unthrown/oxlint plugin (catalog 5.0.0, root devDep, minimumReleaseAgeExclude entry) with the 4 recommended rules and fix the 29 violations it surfaced: - no-ambiguous-error-type (client/src/types.ts): genuine findings — the ClientInferSignal/Query/Update aliases claimed AsyncResult<..., Error> while the handle proxies actually produce the precise {Signal,Query,Update}ValidationError | WorkflowExecutionNotFoundError unions; the aliases now carry those unions, and TypedWorkflowHandle's queries/signals/updates use them directly instead of pattern-matching the bare Error away (which also removes the three `AsyncResult` conditionals in client.ts). - no-ambiguous-error-type (worker/src/activities-proxy.spec.ts): the test fixtures now use the concrete AnyContractError | ActivityError | ActivityCancelledError union the Result-shaped proxy really returns. - prefer-async-result (work thunks feeding makeAsyncResult in client.ts, schedule.ts, cancellation.ts, child-workflow.ts, testing/activity.ts): dropped the Promise> return annotations; where TS cannot collapse the thunk's per-return Result union on its own, the T/E pair is pinned as explicit type arguments on the makeAsyncResult call. - prefer-async-result (async validation helpers resolveDefinitionAndValidateInput / getAndValidateChildWorkflow / validateChildWorkflowOutput): converted to non-async functions returning AsyncResult via makeAsyncResult — behavior is unchanged because callers already assertNoDefect (re-throwing a captured defect into the enclosing throw→defect net). - prefer-async-result (contract/src/result-async.ts and its client-internal re-export wrapper): kept with targeted oxlint-disable-next-line — these ARE the Promise→AsyncResult conversion seam whose thunk rejection becomes the defect, and an async implementer cannot be annotated AsyncResult. Co-Authored-By: Claude Fable 5 --- .oxlintrc.json | 9 +- package.json | 1 + packages/client/src/client.ts | 134 ++++++++++--------- packages/client/src/internal.ts | 1 + packages/client/src/schedule.ts | 6 +- packages/client/src/types.ts | 26 +++- packages/contract/src/result-async.ts | 1 + packages/testing/src/activity.ts | 4 +- packages/worker/src/activities-proxy.spec.ts | 13 +- packages/worker/src/cancellation.ts | 10 +- packages/worker/src/child-workflow.ts | 120 +++++++++-------- pnpm-lock.yaml | 23 ++++ pnpm-workspace.yaml | 2 + 13 files changed, 210 insertions(+), 140 deletions(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index 10192cb6..bb52756e 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,4 +1,11 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", - "extends": ["./node_modules/@btravstack/oxlint/base.json"] + "extends": ["./node_modules/@btravstack/oxlint/base.json"], + "jsPlugins": [{ "name": "unthrown", "specifier": "@unthrown/oxlint" }], + "rules": { + "unthrown/no-ambiguous-error-type": "error", + "unthrown/no-catch-all-pattern": "error", + "unthrown/no-unhandled-result": "error", + "unthrown/prefer-async-result": "error" + } } diff --git a/package.json b/package.json index 376fe09b..951caf00 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@btravstack/oxlint": "catalog:", "@changesets/cli": "catalog:", "@commitlint/cli": "catalog:", + "@unthrown/oxlint": "catalog:", "knip": "catalog:", "lefthook": "catalog:", "oxfmt": "catalog:", diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 964ad54d..68dff5d9 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -296,42 +296,32 @@ export type TypedWorkflowHandle = { readonly firstExecutionRunId: string | undefined; /** - * Type-safe queries based on workflow definition with Result pattern - * Each query returns AsyncResult instead of Promise + * Type-safe queries based on workflow definition with Result pattern. + * Each query returns + * `AsyncResult` + * instead of `Promise` — the error union is carried by + * {@link ClientInferWorkflowQueries} directly. */ - queries: { - [K in keyof ClientInferWorkflowQueries]: ClientInferWorkflowQueries[K] extends ( - ...args: infer Args - ) => AsyncResult - ? (...args: Args) => AsyncResult - : never; - }; + queries: ClientInferWorkflowQueries; /** - * Type-safe signals based on workflow definition with Result pattern - * Each signal returns AsyncResult instead of Promise + * Type-safe signals based on workflow definition with Result pattern. + * Each signal returns + * `AsyncResult` + * instead of `Promise` — the error union is carried by + * {@link ClientInferWorkflowSignals} directly. */ - signals: { - [K in keyof ClientInferWorkflowSignals]: ClientInferWorkflowSignals[K] extends ( - ...args: infer Args - ) => AsyncResult - ? (...args: Args) => AsyncResult - : never; - }; + signals: ClientInferWorkflowSignals; /** * Type-safe updates based on workflow definition with Result pattern. * Each update starts the update AND waits for its result (Temporal's - * `executeUpdate`); use {@link startUpdate} to obtain an update handle - * without waiting for completion. + * `executeUpdate`), returning + * `AsyncResult`; + * use {@link startUpdate} to obtain an update handle without waiting for + * completion. */ - updates: { - [K in keyof ClientInferWorkflowUpdates]: ClientInferWorkflowUpdates[K] extends ( - ...args: infer Args - ) => AsyncResult - ? (...args: Args) => AsyncResult - : never; - }; + updates: ClientInferWorkflowUpdates; /** * Start an update without waiting for its completion — Temporal's @@ -433,7 +423,7 @@ type ResolvedWorkflow = { * extended error classification) stay at the call site — those are the * differentiators that make each method distinct. */ -async function resolveDefinitionAndValidateInput< +function resolveDefinitionAndValidateInput< TContract extends ContractDefinition, TWorkflowName extends keyof TContract["workflows"] & string, >( @@ -442,31 +432,40 @@ async function resolveDefinitionAndValidateInput< workflowId: string, args: unknown, searchAttributes: Record | undefined, -): Promise< - Result< +): AsyncResult< + ResolvedWorkflow, + WorkflowNotInContractError | WorkflowValidationError +> { + return makeAsyncResult< ResolvedWorkflow, WorkflowNotInContractError | WorkflowValidationError - > -> { - const definition = contract.workflows[workflowName]; - if (!definition) { - return Err(createWorkflowNotInContractError(workflowName, contract)); - } + >(async () => { + const definition = contract.workflows[workflowName]; + if (!definition) { + return Err(createWorkflowNotInContractError(workflowName, contract)); + } - const inputResult = await definition.input["~standard"].validate(args); - if (inputResult.issues) { - return Err(new WorkflowValidationError(workflowName, "input", inputResult.issues, workflowId)); - } + const inputResult = await definition.input["~standard"].validate(args); + if (inputResult.issues) { + return Err( + new WorkflowValidationError(workflowName, "input", inputResult.issues, workflowId), + ); + } - // `toTypedSearchAttributes` throws a `RuntimeClientError` on an undeclared - // key or a value that doesn't match the declared kind — a technical - // misconfiguration routed to the defect channel by the enclosing - // `makeAsyncResult` work thunk (never a modeled Err). - const typedSearchAttributes = toTypedSearchAttributes(definition, workflowName, searchAttributes); + // `toTypedSearchAttributes` throws a `RuntimeClientError` on an undeclared + // key or a value that doesn't match the declared kind — a technical + // misconfiguration captured as a defect (never a modeled Err) and + // re-thrown into the caller's throw→defect net by `assertNoDefect`. + const typedSearchAttributes = toTypedSearchAttributes( + definition, + workflowName, + searchAttributes, + ); - return Ok({ - definition: definition as TContract["workflows"][TWorkflowName], - typedSearchAttributes, + return Ok({ + definition: definition as TContract["workflows"][TWorkflowName], + typedSearchAttributes, + }); }); } @@ -552,7 +551,7 @@ export class TypedClient { * ``` */ static create({ client, interceptors }: CreateClientOptions): AsyncResult { - const work = async (): Promise> => { + const work = async () => { // `client.schedule` is the ScheduleClient wired into Temporal's // top-level `Client` since 1.16. The peer dep allows all of `^1`, so a // consumer can be on an older version — fail early with a clear @@ -741,7 +740,7 @@ export class ContractClient { type Ok = TypedWorkflowHandle; type Err = WorkflowNotInContractError | WorkflowValidationError | WorkflowAlreadyStartedError; const runPipeline = (currentInput: unknown): AsyncResult => { - const work = async (): Promise> => { + const work = async () => { const resolved = await resolveDefinitionAndValidateInput( this.contract, workflowName, @@ -749,7 +748,8 @@ export class ContractClient { currentInput, searchAttributes as Record | undefined, ); - // The resolver only ever builds ok/err; assert away the impossible defect. + // A technical throw inside the resolver is captured as a defect; + // re-throw it here so it rides this thunk's throw→defect net. assertNoDefect(resolved); if (resolved.isErr()) return Err(resolved.error); const { definition, typedSearchAttributes } = resolved.value; @@ -778,7 +778,7 @@ export class ContractClient { throw new RuntimeClientError("startWorkflow", error); } }; - return makeAsyncResult(work); + return makeAsyncResult(work); }; // Interceptors wrap the whole pipeline (outside validation), so a @@ -869,7 +869,7 @@ export class ContractClient { currentInput: unknown, currentSignalInput: unknown, ): AsyncResult => { - const work = async (): Promise> => { + const work = async () => { const resolved = await resolveDefinitionAndValidateInput( this.contract, workflowName, @@ -877,7 +877,8 @@ export class ContractClient { currentInput, searchAttributes as Record | undefined, ); - // The resolver only ever builds ok/err; assert away the impossible defect. + // A technical throw inside the resolver is captured as a defect; + // re-throw it here so it rides this thunk's throw→defect net. assertNoDefect(resolved); if (resolved.isErr()) return Err(resolved.error); const { definition, typedSearchAttributes } = resolved.value; @@ -929,7 +930,7 @@ export class ContractClient { throw new RuntimeClientError("signalWithStart", error); } }; - return makeAsyncResult(work); + return makeAsyncResult(work); }; if (this.interceptors.length === 0) return runPipeline(args, signalArgs); @@ -1007,7 +1008,7 @@ export class ContractClient { | WorkflowFailedError | WorkflowExecutionNotFoundError; const runPipeline = (currentInput: unknown): AsyncResult => { - const work = async (): Promise> => { + const work = async () => { const resolved = await resolveDefinitionAndValidateInput( this.contract, workflowName, @@ -1015,7 +1016,8 @@ export class ContractClient { currentInput, searchAttributes as Record | undefined, ); - // The resolver only ever builds ok/err; assert away the impossible defect. + // A technical throw inside the resolver is captured as a defect; + // re-throw it here so it rides this thunk's throw→defect net. assertNoDefect(resolved); if (resolved.isErr()) return Err(resolved.error); const { definition, typedSearchAttributes } = resolved.value; @@ -1065,7 +1067,7 @@ export class ContractClient { throw new RuntimeClientError("executeWorkflow", error); } }; - return makeAsyncResult(work); + return makeAsyncResult(work); }; if (this.interceptors.length === 0) return runPipeline(args); @@ -1194,7 +1196,7 @@ export class ContractClient { workflowId: updateHandle.workflowId, workflowRunId: updateHandle.workflowRunId, result: (): AsyncResult => { - const work = async (): Promise> => { + const work = async () => { try { const raw = await updateHandle.result(); // Receive side of the update-result boundary: the handler @@ -1211,7 +1213,7 @@ export class ContractClient { throw new RuntimeClientError("update.result", error); } }; - return makeAsyncResult(work); + return makeAsyncResult(work); }, }); @@ -1220,7 +1222,7 @@ export class ContractClient { options?: { args?: unknown; updateId?: string; waitForStage?: "ACCEPTED" }, ): AsyncResult => { const runPipeline = (currentInput: unknown): AsyncResult => { - const work = async (): Promise> => { + const work = async () => { const updateDef = (definition.updates as Record | undefined)?.[ updateName ]; @@ -1256,7 +1258,7 @@ export class ContractClient { throw new RuntimeClientError("startUpdate", error); } }; - return makeAsyncResult(work); + return makeAsyncResult(work); }; // Interceptors wrap the whole pipeline (outside validation), same @@ -1296,7 +1298,7 @@ export class ContractClient { | WorkflowValidationError | WorkflowFailedError | WorkflowExecutionNotFoundError; - const work = async (): Promise> => { + const work = async () => { try { const result = await workflowHandle.result(); const outputResult = await definition.output["~standard"].validate(result); @@ -1326,7 +1328,7 @@ export class ContractClient { throw new RuntimeClientError("result", error); } }; - return makeAsyncResult(work); + return makeAsyncResult(work); }, terminate: (reason?: string): AsyncResult => fromPromise( @@ -1436,7 +1438,7 @@ function buildValidatedProxy => { - const work = async (): Promise> => { + const work = async () => { const inputResult = await def.input["~standard"].validate(currentInput); if (inputResult.issues) { return Err(makeValidationError(name, "input", inputResult.issues)); @@ -1461,7 +1463,7 @@ function buildValidatedProxy(work); }; proxy[name] = (args) => { diff --git a/packages/client/src/internal.ts b/packages/client/src/internal.ts index 4e372500..ecf74002 100644 --- a/packages/client/src/internal.ts +++ b/packages/client/src/internal.ts @@ -159,6 +159,7 @@ export function toTypedSearchAttributes( * `@temporal-contract/contract` so the same wrapper is shared between the * client and worker packages. */ +// oxlint-disable-next-line unthrown/prefer-async-result -- this IS the Promise→AsyncResult conversion seam: the work thunk's throw/rejection is what becomes the defect, and an async implementer cannot be annotated AsyncResult export function makeAsyncResult(work: () => Promise>): AsyncResult { return _internal_makeAsyncResult(work); } diff --git a/packages/client/src/schedule.ts b/packages/client/src/schedule.ts index 163f1528..d2d080fb 100644 --- a/packages/client/src/schedule.ts +++ b/packages/client/src/schedule.ts @@ -13,7 +13,7 @@ import type { ScheduleUpdateOptions, Workflow, } from "@temporalio/client"; -import { type AsyncResult, type Result, Ok, Err, fromPromise } from "unthrown"; +import { type AsyncResult, Ok, Err, fromPromise } from "unthrown"; import type { TypedSearchAttributeMap } from "./client.js"; import { @@ -185,7 +185,7 @@ export class TypedScheduleClient { > { type Ok = TypedScheduleHandle; type Err = WorkflowNotInContractError | WorkflowValidationError | ScheduleAlreadyExistsError; - const work = async (): Promise> => { + const work = async () => { const definition = this.contract.workflows[workflowName]; if (!definition) { return Err( @@ -258,7 +258,7 @@ export class TypedScheduleClient { throw new RuntimeClientError("schedule.create", error); } }; - return makeAsyncResult(work); + return makeAsyncResult(work); } /** diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index 5a37ab06..427c6f8c 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -6,6 +6,13 @@ import type { } from "@temporal-contract/contract"; import type { AsyncResult } from "unthrown"; +import type { + QueryValidationError, + SignalValidationError, + UpdateValidationError, + WorkflowExecutionNotFoundError, +} from "./errors.js"; + // The direction-aware schema inference primitives live in // `@temporal-contract/contract` (single source of truth shared with the // worker package); re-exported so the client's public type surface is @@ -27,36 +34,45 @@ import type { ClientInferInput, ClientInferOutput } from "@temporal-contract/con * Infer signal handler signature from client perspective. * Client sends the signal input type; the payload argument is omittable * when the schema accepts `undefined` (e.g. payload-less `defineSignal()`). + * The error union names exactly what the handle's signal proxy produces: + * input-validation failure or a missing execution. */ export type ClientInferSignal = ( ...args: undefined extends ClientInferInput ? [input?: ClientInferInput] : [input: ClientInferInput] -) => AsyncResult; +) => AsyncResult; /** * Infer query handler signature from client perspective. * Client sends the query input type and receives the output type wrapped in - * `AsyncResult`; the payload argument is omittable when the schema + * an `AsyncResult`; the payload argument is omittable when the schema * accepts `undefined` (e.g. argument-less `defineQuery({ output })`). + * The error union names exactly what the handle's query proxy produces: + * input/output-validation failure or a missing execution. */ export type ClientInferQuery = ( ...args: undefined extends ClientInferInput ? [input?: ClientInferInput] : [input: ClientInferInput] -) => AsyncResult, Error>; +) => AsyncResult, QueryValidationError | WorkflowExecutionNotFoundError>; /** * Infer update handler signature from client perspective. * Client sends the update input type and receives the output type wrapped in - * `AsyncResult`; the payload argument is omittable when the schema + * an `AsyncResult`; the payload argument is omittable when the schema * accepts `undefined` (e.g. argument-less `defineUpdate({ output })`). + * The error union names exactly what the handle's update proxy produces: + * input/output-validation failure or a missing execution. */ export type ClientInferUpdate = ( ...args: undefined extends ClientInferInput ? [input?: ClientInferInput] : [input: ClientInferInput] -) => AsyncResult, Error>; +) => AsyncResult< + ClientInferOutput, + UpdateValidationError | WorkflowExecutionNotFoundError +>; /** * Infer signals from a workflow definition (client perspective) diff --git a/packages/contract/src/result-async.ts b/packages/contract/src/result-async.ts index 63a8b216..404bb8ca 100644 --- a/packages/contract/src/result-async.ts +++ b/packages/contract/src/result-async.ts @@ -37,6 +37,7 @@ import { * sibling client and worker packages. Not part of the public API. */ export function _internal_makeAsyncResult( + // oxlint-disable-next-line unthrown/prefer-async-result -- this IS the Promise→AsyncResult conversion seam: the work thunk's throw/rejection is what becomes the defect, and an async implementer cannot be annotated AsyncResult work: () => Promise>, ): AsyncResult { return fromSafePromise(work).flatMap((inner) => inner); diff --git a/packages/testing/src/activity.ts b/packages/testing/src/activity.ts index 5d629fbe..767eaf4c 100644 --- a/packages/testing/src/activity.ts +++ b/packages/testing/src/activity.ts @@ -23,7 +23,7 @@ import { } from "@temporal-contract/contract/errors"; import { _internal_makeAsyncResult } from "@temporal-contract/contract/result-async"; import { MockActivityEnvironment } from "@temporalio/testing"; -import type { AsyncResult, Result } from "unthrown"; +import type { AsyncResult } from "unthrown"; /** * Typed error constructors for an activity's declared `errors` map — the @@ -106,7 +106,7 @@ export function runActivity - env.run(async (): Promise> => { + env.run(async () => { // Awaiting the AsyncResult yields its settled Result (ok / err / // defect) without throwing; the outer wrapper re-lifts it, and any // synchronous throw or rejection lands on the defect channel. diff --git a/packages/worker/src/activities-proxy.spec.ts b/packages/worker/src/activities-proxy.spec.ts index 8e157975..6f6d11bf 100644 --- a/packages/worker/src/activities-proxy.spec.ts +++ b/packages/worker/src/activities-proxy.spec.ts @@ -1,5 +1,5 @@ import type { ActivityDefinition } from "@temporal-contract/contract"; -import { ContractError } from "@temporal-contract/contract/errors"; +import { type AnyContractError, ContractError } from "@temporal-contract/contract/errors"; import { ApplicationFailure, CancelledFailure } from "@temporalio/common"; import type { AsyncResult } from "unthrown"; /** @@ -15,6 +15,13 @@ import { z } from "zod"; import { createValidatedActivities } from "./activities-proxy.js"; import { ActivityCancelledError, ActivityError } from "./errors.js"; +/** + * The error union the Result-shaped proxy actually produces for activities + * with a declared `errors` map — typed rehydrations plus the two + * classification fallbacks (see `createValidatedActivities`). + */ +type ProxyError = AnyContractError | ActivityError | ActivityCancelledError; + const erroredDefinition = { input: z.object({ amount: z.number() }), output: z.object({ transactionId: z.string() }), @@ -36,7 +43,7 @@ const buildProxy = (raw: (...args: unknown[]) => Promise) => { chargePayment: raw }, { chargePayment: erroredDefinition }, undefined, - ) as unknown as Record AsyncResult>; + ) as unknown as Record AsyncResult>; describe("createValidatedActivities — activities without declared errors", () => { it("keeps the historical throwing Promise shape", async () => { @@ -106,7 +113,7 @@ describe("createValidatedActivities — wire format (validate on send, parse on }, { transformer: transformErroredDefinition }, undefined, - ) as unknown as Record AsyncResult>; + ) as unknown as Record AsyncResult>; const result = await activities["transformer"]!({ text: "hi" }); diff --git a/packages/worker/src/cancellation.ts b/packages/worker/src/cancellation.ts index f19de678..22069685 100644 --- a/packages/worker/src/cancellation.ts +++ b/packages/worker/src/cancellation.ts @@ -13,7 +13,7 @@ * cancellation. */ import { CancellationScope, isCancellation } from "@temporalio/workflow"; -import { type AsyncResult, type Result, Ok, Err } from "unthrown"; +import { type AsyncResult, Ok, Err } from "unthrown"; import { WorkflowCancelledError } from "./errors.js"; import { makeAsyncResult } from "./internal.js"; @@ -52,7 +52,7 @@ import { makeAsyncResult } from "./internal.js"; export function cancellableScope( fn: () => T | Promise, ): AsyncResult { - const work = async (): Promise> => { + const work = async () => { try { // Wrap so synchronous returns satisfy CancellationScope.cancellable's // `() => Promise` signature without forcing every caller to write @@ -68,7 +68,7 @@ export function cancellableScope( throw error; } }; - return makeAsyncResult(work); + return makeAsyncResult(work); } /** @@ -92,7 +92,7 @@ export function cancellableScope( export function nonCancellableScope( fn: () => T | Promise, ): AsyncResult { - const work = async (): Promise> => { + const work = async () => { try { const value = await CancellationScope.nonCancellable(async () => fn()); return Ok(value); @@ -103,5 +103,5 @@ export function nonCancellableScope( throw error; } }; - return makeAsyncResult(work); + return makeAsyncResult(work); } diff --git a/packages/worker/src/child-workflow.ts b/packages/worker/src/child-workflow.ts index 865eb0f6..7a8ba1e5 100644 --- a/packages/worker/src/child-workflow.ts +++ b/packages/worker/src/child-workflow.ts @@ -17,7 +17,7 @@ import { startChild, type Workflow, } from "@temporalio/workflow"; -import { type AsyncResult, type Result, Ok, Err } from "unthrown"; +import { type AsyncResult, Ok, Err } from "unthrown"; import { type ChildWorkflowCancelledError, @@ -98,20 +98,22 @@ export type TypedChildWorkflowHandle = * and transmitted the original value, so the parse (and any schema * transform) happens exactly once, here. */ -async function validateChildWorkflowOutput( +function validateChildWorkflowOutput( childDefinition: TChildWorkflow, result: unknown, childWorkflowName: string, -): Promise, ChildWorkflowError>> { - const outputResult = await childDefinition.output["~standard"].validate(result); - if (outputResult.issues) { - return Err( - new ChildWorkflowError( - formatChildWorkflowValidationMessage(childWorkflowName, "output", outputResult.issues), - ), - ); - } - return Ok(outputResult.value as ClientInferOutput); +): AsyncResult, ChildWorkflowError> { + return makeAsyncResult, ChildWorkflowError>(async () => { + const outputResult = await childDefinition.output["~standard"].validate(result); + if (outputResult.issues) { + return Err( + new ChildWorkflowError( + formatChildWorkflowValidationMessage(childWorkflowName, "output", outputResult.issues), + ), + ); + } + return Ok(outputResult.value as ClientInferOutput); + }); } /** @@ -121,45 +123,51 @@ async function validateChildWorkflowOutput( childContract: TChildContract, childWorkflowName: TChildWorkflowName, args: unknown, -): Promise< - Result< +): AsyncResult< + { + definition: TChildContract["workflows"][TChildWorkflowName]; + taskQueue: string; + }, + ChildWorkflowError | ChildWorkflowNotFoundError +> { + return makeAsyncResult< { definition: TChildContract["workflows"][TChildWorkflowName]; taskQueue: string; }, ChildWorkflowError | ChildWorkflowNotFoundError - > -> { - const childDefinition = childContract.workflows[childWorkflowName]; + >(async () => { + const childDefinition = childContract.workflows[childWorkflowName]; - if (!childDefinition) { - return Err( - new ChildWorkflowNotFoundError( - childWorkflowName, - Object.keys(childContract.workflows) as string[], - ), - ); - } + if (!childDefinition) { + return Err( + new ChildWorkflowNotFoundError( + childWorkflowName, + Object.keys(childContract.workflows) as string[], + ), + ); + } - const inputResult = await childDefinition.input["~standard"].validate(args); - if (inputResult.issues) { - return Err( - new ChildWorkflowError( - formatChildWorkflowValidationMessage(childWorkflowName, "input", inputResult.issues), - ), - ); - } + const inputResult = await childDefinition.input["~standard"].validate(args); + if (inputResult.issues) { + return Err( + new ChildWorkflowError( + formatChildWorkflowValidationMessage(childWorkflowName, "input", inputResult.issues), + ), + ); + } - return Ok({ - definition: childDefinition as TChildContract["workflows"][TChildWorkflowName], - taskQueue: childContract.taskQueue, + return Ok({ + definition: childDefinition as TChildContract["workflows"][TChildWorkflowName], + taskQueue: childContract.taskQueue, + }); }); } @@ -184,9 +192,7 @@ function createTypedChildSignals( const signalDefs = (childDefinition.signals ?? {}) as Record; for (const [signalName, signalDef] of Object.entries(signalDefs)) { signals[signalName] = (args: unknown) => { - const work = async (): Promise< - Result - > => { + const work = async () => { const inputResult = await signalDef.input["~standard"].validate(args); if (inputResult.issues) { return Err( @@ -204,7 +210,7 @@ function createTypedChildSignals( return Err(classifyChildWorkflowError("signal", error, childWorkflowName)); } }; - return makeAsyncResult(work); + return makeAsyncResult(work); }; } @@ -224,9 +230,7 @@ function createTypedChildHandle( ClientInferOutput, ChildWorkflowError | ChildWorkflowCancelledError > => { - const work = async (): Promise< - Result, ChildWorkflowError | ChildWorkflowCancelledError> - > => { + const work = async () => { try { const result = await handle.result(); return validateChildWorkflowOutput(childDefinition, result, childWorkflowName); @@ -234,7 +238,10 @@ function createTypedChildHandle( return Err(classifyChildWorkflowError("result", error, childWorkflowName)); } }; - return makeAsyncResult(work); + return makeAsyncResult< + ClientInferOutput, + ChildWorkflowError | ChildWorkflowCancelledError + >(work); }, }; } @@ -251,17 +258,16 @@ export function createStartChildWorkflow< ChildWorkflowError | ChildWorkflowCancelledError | ChildWorkflowNotFoundError > { type Ok = TypedChildWorkflowHandle; - const work = async (): Promise< - Result - > => { + const work = async () => { const validationResult = await getAndValidateChildWorkflow( childContract, childWorkflowName, options.args, ); - // `getAndValidateChildWorkflow` only ever builds ok/err; assert away the - // impossible defect so `.error` / `.value` narrow cleanly below. + // A technical throw inside the validator is captured as a defect; + // re-throw it here (into this thunk's throw→defect net) so `.error` / + // `.value` narrow cleanly below. assertNoDefect(validationResult); if (validationResult.isErr()) { return Err(validationResult.error); @@ -286,7 +292,10 @@ export function createStartChildWorkflow< return Err(classifyChildWorkflowError("startChild", error, String(childWorkflowName))); } }; - return makeAsyncResult(work); + return makeAsyncResult< + Ok, + ChildWorkflowError | ChildWorkflowCancelledError | ChildWorkflowNotFoundError + >(work); } export function createExecuteChildWorkflow< @@ -301,9 +310,7 @@ export function createExecuteChildWorkflow< ChildWorkflowError | ChildWorkflowCancelledError | ChildWorkflowNotFoundError > { type Ok = ClientInferOutput; - const work = async (): Promise< - Result - > => { + const work = async () => { const validationResult = await getAndValidateChildWorkflow( childContract, childWorkflowName, @@ -343,5 +350,8 @@ export function createExecuteChildWorkflow< return Err(classifyChildWorkflowError("executeChild", error, String(childWorkflowName))); } }; - return makeAsyncResult(work); + return makeAsyncResult< + Ok, + ChildWorkflowError | ChildWorkflowCancelledError | ChildWorkflowNotFoundError + >(work); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 140f29bf..31c039ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,6 +54,9 @@ catalogs: '@types/node': specifier: 26.1.1 version: 26.1.1 + '@unthrown/oxlint': + specifier: 5.0.0 + version: 5.0.0 '@unthrown/vitest': specifier: 5.0.0 version: 5.0.0 @@ -145,6 +148,9 @@ importers: '@commitlint/cli': specifier: 'catalog:' version: 21.2.1(@types/node@26.1.1)(conventional-commits-parser@7.0.1)(typescript@6.0.3) + '@unthrown/oxlint': + specifier: 'catalog:' + version: 5.0.0(oxlint@1.74.0) knip: specifier: 'catalog:' version: 6.27.0 @@ -1940,6 +1946,10 @@ packages: cpu: [x64] os: [win32] + '@oxlint/plugins@1.75.0': + resolution: {integrity: sha512-dNQBRuvkeecm9nxi1cXRxXA1oAyqJqHof6cdnjY/WDBU1nZ9V07rp9X9gG7+JgShrjJW4VR2dTo7t2EcX4XD8g==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -2589,6 +2599,12 @@ packages: '@ungap/structured-clone@1.3.1': resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} + '@unthrown/oxlint@5.0.0': + resolution: {integrity: sha512-omNWxGLFYI3DcBzvGtnDDSTG4epZOdOi7XuAxLkjDE8EyqVA6qFJ/gtJOeFEoZP6aNogYuMvh5hAlGNn8u7cEA==} + engines: {node: '>=20'} + peerDependencies: + oxlint: ^1.69.0 + '@unthrown/vitest@5.0.0': resolution: {integrity: sha512-YbyqZE+JnPVMkDGRY+cFUY27TEbp7O+mLMF5Clf6zxneTEoU6du0G/xnjQNd/R/Gib8jyfuuLL3tDzwF6YhJiA==} engines: {node: '>=20'} @@ -6440,6 +6456,8 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.74.0': optional: true + '@oxlint/plugins@1.75.0': {} + '@pinojs/redact@0.4.0': {} '@pkgjs/parseargs@0.11.0': @@ -7048,6 +7066,11 @@ snapshots: '@ungap/structured-clone@1.3.1': {} + '@unthrown/oxlint@5.0.0(oxlint@1.74.0)': + dependencies: + '@oxlint/plugins': 1.75.0 + oxlint: 1.74.0 + '@unthrown/vitest@5.0.0(unthrown@5.0.0)(vitest@4.1.10)': dependencies: unthrown: 5.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d68f9127..20a8325d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -14,6 +14,7 @@ catalog: "@btravstack/commitlint": 0.1.0 "@btravstack/lefthook": 0.1.1 "@btravstack/oxlint": 0.2.1 + "@unthrown/oxlint": 5.0.0 "@btravstack/theme": 1.7.0 "@btravstack/tsconfig": 0.2.0 "@btravstack/typedoc": 0.1.0 @@ -107,6 +108,7 @@ minimumReleaseAgeExclude: - "@btravstack/tsconfig" - "@btravstack/typedoc" - "unthrown" + - "@unthrown/oxlint" - "@unthrown/vitest" # Temporary: fast-uri 3.1.4 (the GHSA-v2hh-gcrm-f6hx override above) is younger # than the 7-day cutoff. Remove once 3.1.4 is 7 days old (published 2026-07-19). From e047b4775c65a29f1cab032742ea9554397a2677 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 30 Jul 2026 10:24:44 +0200 Subject: [PATCH 2/3] chore: enable all @unthrown/oxlint rules and fix the docs build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docs build (root cause of the Package / ci Build / Bundle Size failures): the TSDoc on TypedWorkflowHandle's queries/signals/updates properties held inline code spans containing a raw `|` (e.g. AsyncResult). typedoc renders member docs into markdown table cells without escaping pipes inside code spans, so markdown-it split the row at the pipe, the broken span left `Promise` as a raw HTML-looking `` element, and VitePress's Vue compiler failed with "Element is missing end tag" at api/client/index.md. Fixed at the source by rewording the three doc comments so no code span carries a pipe; generated files untouched. Lint: turn on unthrown/no-throw and unthrown/prefer-ensure, so all six rules are now at "error". prefer-ensure surfaced 0 violations. no-throw surfaced 148: - 76 in test files, handled by a config override turning only no-throw off for **/*.spec.ts and **/__tests__/** (tests throw to simulate defects and assert rejections; the other five rules stay on). - 72 in source, each carrying a targeted oxlint-disable naming its category — none could be restructured to Err(...) without changing semantics: - 33 sanctioned ValidationError / ContractMisuseError / ApplicationFailure sites (CLAUDE.md rule 2 exception — Temporal's terminal-failure semantics require the throw) - 12 client-side technical faults thrown inside makeAsyncResult work thunks (RuntimeClientError/TechnicalError — the throw IS the defect-channel routing) - 6 defect-cause / workflow-sandbox rethrows (unmodeled failures kept on the defect channel) - 1 cancellation rethrow (CancelledFailure must propagate) - 7 declaration-time fail-fast config errors (worker startup / contract definition / test setup) - 13 example-domain throws in plain Promise helpers wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...)) Co-Authored-By: Claude Fable 5 --- .oxlintrc.json | 14 +++++++-- .../src/application/workflows.ts | 3 ++ .../usecases/create-shipment.usecase.ts | 2 ++ .../usecases/process-payment.usecase.ts | 2 ++ .../usecases/purge-expired-orders.usecase.ts | 1 + .../domain/usecases/refund-payment.usecase.ts | 1 + .../usecases/release-inventory.usecase.ts | 1 + .../usecases/reserve-inventory.usecase.ts | 2 ++ .../usecases/send-notification.usecase.ts | 3 ++ .../adapters/payment.adapter.ts | 1 + packages/client/src/client.ts | 29 ++++++++++++------- packages/client/src/internal.ts | 2 ++ packages/client/src/schedule.ts | 1 + packages/contract/src/builder.ts | 1 + packages/contract/src/result-async.ts | 1 + packages/testing/src/extension.ts | 1 + packages/worker/src/activities-proxy.ts | 3 ++ packages/worker/src/activity.ts | 10 +++++++ packages/worker/src/cancellation.ts | 2 ++ packages/worker/src/contract-errors.ts | 2 ++ packages/worker/src/handlers.ts | 15 ++++++++++ packages/worker/src/internal.ts | 4 +++ packages/worker/src/workflow.ts | 5 ++++ 23 files changed, 94 insertions(+), 12 deletions(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index bb52756e..9200408b 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -5,7 +5,17 @@ "rules": { "unthrown/no-ambiguous-error-type": "error", "unthrown/no-catch-all-pattern": "error", + "unthrown/no-throw": "error", "unthrown/no-unhandled-result": "error", - "unthrown/prefer-async-result": "error" - } + "unthrown/prefer-async-result": "error", + "unthrown/prefer-ensure": "error" + }, + "overrides": [ + { + "files": ["**/*.spec.ts", "**/__tests__/**"], + "rules": { + "unthrown/no-throw": "off" + } + } + ] } diff --git a/examples/order-processing-worker/src/application/workflows.ts b/examples/order-processing-worker/src/application/workflows.ts index f1717824..699c1ce8 100644 --- a/examples/order-processing-worker/src/application/workflows.ts +++ b/examples/order-processing-worker/src/application/workflows.ts @@ -150,6 +150,7 @@ export const processOrder = declareWorkflow({ if (paymentResult.isDefect()) { // Unmodeled failure (a bug, not an anticipated outcome) — rethrow at // the edge so Temporal surfaces the Workflow Task failure. + // oxlint-disable-next-line unthrown/no-throw -- defect-cause rethrow at the edge: an unmodeled failure must surface as a Workflow Task failure throw paymentResult.cause; } const failure = paymentResult.error; @@ -169,6 +170,7 @@ export const processOrder = declareWorkflow({ // Rethrow as this workflow's own declared contract error: the // execution fails with `ApplicationFailure(type: "PaymentDeclined")` // and the typed client rehydrates it into a `ContractError`. + // oxlint-disable-next-line unthrown/no-throw -- sanctioned ApplicationFailure model: `throw context.errors.X(...)` is how a workflow fails terminally with a typed contract error (CLAUDE.md rule 2 exception) throw context.errors.PaymentDeclined({ reason: failure.data.reason }, { cause: failure }); } case "@temporal-contract/ActivityError": @@ -244,6 +246,7 @@ export const processOrder = declareWorkflow({ // Cancellation must propagate — swallowing it here would leave the // workflow running after a cancel request. if (isCancellation(error)) { + // oxlint-disable-next-line unthrown/no-throw -- cancellation rethrow: swallowing a CancelledFailure would leave the workflow running after a cancel request throw error; } // Non-critical: log but continue diff --git a/examples/order-processing-worker/src/domain/usecases/create-shipment.usecase.ts b/examples/order-processing-worker/src/domain/usecases/create-shipment.usecase.ts index a5933b5a..549b612e 100644 --- a/examples/order-processing-worker/src/domain/usecases/create-shipment.usecase.ts +++ b/examples/order-processing-worker/src/domain/usecases/create-shipment.usecase.ts @@ -12,10 +12,12 @@ export class CreateShipmentUseCase { async execute(orderId: string, customerId: string): Promise { // Business validation if (!orderId || orderId.trim() === "") { + // oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...)) throw new Error("Order ID is required"); } if (!customerId || customerId.trim() === "") { + // oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...)) throw new Error("Customer ID is required"); } diff --git a/examples/order-processing-worker/src/domain/usecases/process-payment.usecase.ts b/examples/order-processing-worker/src/domain/usecases/process-payment.usecase.ts index 6a67469a..c106f64c 100644 --- a/examples/order-processing-worker/src/domain/usecases/process-payment.usecase.ts +++ b/examples/order-processing-worker/src/domain/usecases/process-payment.usecase.ts @@ -12,10 +12,12 @@ export class ProcessPaymentUseCase { async execute(customerId: string, amount: number): Promise { // Business validation if (amount <= 0) { + // oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...)) throw new Error("Payment amount must be positive"); } if (!customerId || customerId.trim() === "") { + // oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...)) throw new Error("Customer ID is required"); } diff --git a/examples/order-processing-worker/src/domain/usecases/purge-expired-orders.usecase.ts b/examples/order-processing-worker/src/domain/usecases/purge-expired-orders.usecase.ts index e2fd8e80..37e79e03 100644 --- a/examples/order-processing-worker/src/domain/usecases/purge-expired-orders.usecase.ts +++ b/examples/order-processing-worker/src/domain/usecases/purge-expired-orders.usecase.ts @@ -11,6 +11,7 @@ export class PurgeExpiredOrdersUseCase { async execute(olderThanDays: number): Promise { // Business validation if (!Number.isInteger(olderThanDays) || olderThanDays <= 0) { + // oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...)) throw new Error("olderThanDays must be a positive integer"); } diff --git a/examples/order-processing-worker/src/domain/usecases/refund-payment.usecase.ts b/examples/order-processing-worker/src/domain/usecases/refund-payment.usecase.ts index 62d08f61..2a3067ed 100644 --- a/examples/order-processing-worker/src/domain/usecases/refund-payment.usecase.ts +++ b/examples/order-processing-worker/src/domain/usecases/refund-payment.usecase.ts @@ -11,6 +11,7 @@ export class RefundPaymentUseCase { async execute(transactionId: string): Promise { // Business validation if (!transactionId || transactionId.trim() === "") { + // oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...)) throw new Error("Transaction ID is required"); } diff --git a/examples/order-processing-worker/src/domain/usecases/release-inventory.usecase.ts b/examples/order-processing-worker/src/domain/usecases/release-inventory.usecase.ts index cb5b3182..a7d2acba 100644 --- a/examples/order-processing-worker/src/domain/usecases/release-inventory.usecase.ts +++ b/examples/order-processing-worker/src/domain/usecases/release-inventory.usecase.ts @@ -11,6 +11,7 @@ export class ReleaseInventoryUseCase { async execute(reservationId: string): Promise { // Business validation if (!reservationId || reservationId.trim() === "") { + // oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...)) throw new Error("Reservation ID is required"); } diff --git a/examples/order-processing-worker/src/domain/usecases/reserve-inventory.usecase.ts b/examples/order-processing-worker/src/domain/usecases/reserve-inventory.usecase.ts index 3a9cf28d..b96cec75 100644 --- a/examples/order-processing-worker/src/domain/usecases/reserve-inventory.usecase.ts +++ b/examples/order-processing-worker/src/domain/usecases/reserve-inventory.usecase.ts @@ -12,11 +12,13 @@ export class ReserveInventoryUseCase { async execute(items: OrderItem[]): Promise { // Business validation if (!items || items.length === 0) { + // oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...)) throw new Error("At least one item is required"); } for (const item of items) { if (item.quantity <= 0) { + // oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...)) throw new Error(`Invalid quantity for product ${item.productId}`); } } diff --git a/examples/order-processing-worker/src/domain/usecases/send-notification.usecase.ts b/examples/order-processing-worker/src/domain/usecases/send-notification.usecase.ts index 01d110fd..e467a5e0 100644 --- a/examples/order-processing-worker/src/domain/usecases/send-notification.usecase.ts +++ b/examples/order-processing-worker/src/domain/usecases/send-notification.usecase.ts @@ -11,14 +11,17 @@ export class SendNotificationUseCase { async execute(customerId: string, subject: string, message: string): Promise { // Business validation if (!customerId || customerId.trim() === "") { + // oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...)) throw new Error("Customer ID is required"); } if (!subject || subject.trim() === "") { + // oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...)) throw new Error("Subject is required"); } if (!message || message.trim() === "") { + // oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...)) throw new Error("Message is required"); } diff --git a/examples/order-processing-worker/src/infrastructure/adapters/payment.adapter.ts b/examples/order-processing-worker/src/infrastructure/adapters/payment.adapter.ts index b3e7bb6a..f613ff96 100644 --- a/examples/order-processing-worker/src/infrastructure/adapters/payment.adapter.ts +++ b/examples/order-processing-worker/src/infrastructure/adapters/payment.adapter.ts @@ -56,6 +56,7 @@ export class MockPaymentAdapter implements PaymentPort { logger.info(`✅ Refund successful`); } else { logger.error(`❌ Refund failed`); + // oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain (non-Result) domain helper, wrapped once at the activity boundary via fromPromise(..., qualifyFailure(...)) throw new Error("Payment processor rejected refund request"); } } diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 68dff5d9..4e091657 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -297,18 +297,18 @@ export type TypedWorkflowHandle = { /** * Type-safe queries based on workflow definition with Result pattern. - * Each query returns - * `AsyncResult` - * instead of `Promise` — the error union is carried by - * {@link ClientInferWorkflowQueries} directly. + * Each query returns an `AsyncResult` — erring with `QueryValidationError` + * or `WorkflowExecutionNotFoundError` — instead of a throwing `Promise`; + * the error union is carried by {@link ClientInferWorkflowQueries} + * directly. */ queries: ClientInferWorkflowQueries; /** * Type-safe signals based on workflow definition with Result pattern. - * Each signal returns - * `AsyncResult` - * instead of `Promise` — the error union is carried by + * Each signal returns an `AsyncResult` — erring with + * `SignalValidationError` or `WorkflowExecutionNotFoundError` — instead of + * a throwing `Promise`; the error union is carried by * {@link ClientInferWorkflowSignals} directly. */ signals: ClientInferWorkflowSignals; @@ -316,9 +316,9 @@ export type TypedWorkflowHandle = { /** * Type-safe updates based on workflow definition with Result pattern. * Each update starts the update AND waits for its result (Temporal's - * `executeUpdate`), returning - * `AsyncResult`; - * use {@link startUpdate} to obtain an update handle without waiting for + * `executeUpdate`), returning an `AsyncResult` that errs with + * `UpdateValidationError` or `WorkflowExecutionNotFoundError`; use + * {@link startUpdate} to obtain an update handle without waiting for * completion. */ updates: ClientInferWorkflowUpdates; @@ -562,6 +562,7 @@ export class TypedClient { if (!client.schedule) { // Technical setup fault — `makeAsyncResult`'s throw→defect net // routes it to the defect channel (never a modeled Err). + // oxlint-disable-next-line unthrown/no-throw -- defect-channel routing: this throw inside the makeAsyncResult work thunk IS how a technical fault becomes a defect, never a modeled Err throw new TechnicalError( "TypedClient requires @temporalio/client >= 1.16 (the Schedule API was added in 1.16). " + "Found a Client instance without a `schedule` property — please upgrade.", @@ -579,6 +580,7 @@ export class TypedClient { await connection.ensureConnected(); } catch (error) { // Technical connection fault — route to the defect channel too. + // oxlint-disable-next-line unthrown/no-throw -- defect-channel routing: this throw inside the makeAsyncResult work thunk IS how a technical fault becomes a defect, never a modeled Err throw new TechnicalError("Failed to connect to Temporal server", error); } } @@ -775,6 +777,7 @@ export class ContractClient { const alreadyStarted = classifyStartError(error); if (alreadyStarted) return Err(alreadyStarted); // Unrecognized, technical failure — route to the defect channel. + // oxlint-disable-next-line unthrown/no-throw -- defect-channel routing: this throw inside the makeAsyncResult work thunk IS how a technical fault becomes a defect, never a modeled Err throw new RuntimeClientError("startWorkflow", error); } }; @@ -927,6 +930,7 @@ export class ContractClient { const alreadyStarted = classifyStartError(error); if (alreadyStarted) return Err(alreadyStarted); // Unrecognized, technical failure — route to the defect channel. + // oxlint-disable-next-line unthrown/no-throw -- defect-channel routing: this throw inside the makeAsyncResult work thunk IS how a technical fault becomes a defect, never a modeled Err throw new RuntimeClientError("signalWithStart", error); } }; @@ -1064,6 +1068,7 @@ export class ContractClient { ); if (classified) return Err(classified as Err); // Unrecognized, technical failure — route to the defect channel. + // oxlint-disable-next-line unthrown/no-throw -- defect-channel routing: this throw inside the makeAsyncResult work thunk IS how a technical fault becomes a defect, never a modeled Err throw new RuntimeClientError("executeWorkflow", error); } }; @@ -1210,6 +1215,7 @@ export class ContractClient { const notFound = classifyHandleError(error, updateHandle.workflowId); if (notFound) return Err(notFound); // Unrecognized, technical failure — route to the defect channel. + // oxlint-disable-next-line unthrown/no-throw -- defect-channel routing: this throw inside the makeAsyncResult work thunk IS how a technical fault becomes a defect, never a modeled Err throw new RuntimeClientError("update.result", error); } }; @@ -1255,6 +1261,7 @@ export class ContractClient { const notFound = classifyHandleError(error, workflowHandle.workflowId); if (notFound) return Err(notFound); // Unrecognized, technical failure — route to the defect channel. + // oxlint-disable-next-line unthrown/no-throw -- defect-channel routing: this throw inside the makeAsyncResult work thunk IS how a technical fault becomes a defect, never a modeled Err throw new RuntimeClientError("startUpdate", error); } }; @@ -1325,6 +1332,7 @@ export class ContractClient { ); if (classified) return Err(classified as Err); // Unrecognized, technical failure — route to the defect channel. + // oxlint-disable-next-line unthrown/no-throw -- defect-channel routing: this throw inside the makeAsyncResult work thunk IS how a technical fault becomes a defect, never a modeled Err throw new RuntimeClientError("result", error); } }; @@ -1460,6 +1468,7 @@ function buildValidatedProxy { const alreadyExists = classifyScheduleCreateError(error, options.scheduleId); if (alreadyExists) return Err(alreadyExists); // Unrecognized, technical failure — route to the defect channel. + // oxlint-disable-next-line unthrown/no-throw -- defect-channel routing: this throw inside the makeAsyncResult work thunk IS how a technical fault becomes a defect, never a modeled Err throw new RuntimeClientError("schedule.create", error); } }; diff --git a/packages/contract/src/builder.ts b/packages/contract/src/builder.ts index 8af19bdd..84e0fcc4 100644 --- a/packages/contract/src/builder.ts +++ b/packages/contract/src/builder.ts @@ -460,6 +460,7 @@ const RETRY_KEYS = [ /** Throw the canonical single-line contract validation error. */ function fail(detail: string): never { + // oxlint-disable-next-line unthrown/no-throw -- declaration-time fail-fast config error: an invalid contract must abort at definition, before any Result seam exists throw new Error(`Contract validation failed: ${detail}`); } diff --git a/packages/contract/src/result-async.ts b/packages/contract/src/result-async.ts index 404bb8ca..8487623c 100644 --- a/packages/contract/src/result-async.ts +++ b/packages/contract/src/result-async.ts @@ -62,6 +62,7 @@ export function _internal_assertNoDefect( result: Result, ): asserts result is OkView | ErrView { if (result.isDefect()) { + // oxlint-disable-next-line unthrown/no-throw -- defect-cause rethrow at the boundary: a genuine bug must keep riding the defect channel throw result.cause; } } diff --git a/packages/testing/src/extension.ts b/packages/testing/src/extension.ts index 5879182d..506d4ddc 100644 --- a/packages/testing/src/extension.ts +++ b/packages/testing/src/extension.ts @@ -58,6 +58,7 @@ function getTemporalWorkerConnection(): Promise { */ export function resolveTemporalAddress(host: string | undefined, port: number | undefined): string { if (host === undefined || port === undefined) { + // oxlint-disable-next-line unthrown/no-throw -- declaration-time fail-fast config error: missing global-setup injection must abort the test run with a descriptive message throw new Error( "Temporal test-server address was not injected into this test project. " + 'Register the testcontainers global setup in your vitest config — globalSetup: "@temporal-contract/testing/global-setup" ' + diff --git a/packages/worker/src/activities-proxy.ts b/packages/worker/src/activities-proxy.ts index a8a34eff..66c829a8 100644 --- a/packages/worker/src/activities-proxy.ts +++ b/packages/worker/src/activities-proxy.ts @@ -133,6 +133,7 @@ export function createValidatedActivities< const rawActivity = rawActivities[activityName]; if (!rawActivity) { + // oxlint-disable-next-line unthrown/no-throw -- declaration-time fail-fast config error: a missing implementation is a wiring bug surfaced at proxy construction throw new Error( `Activity implementation not found for: "${activityName}". ` + `Available activities: ${Object.keys(rawActivities).length > 0 ? Object.keys(rawActivities).join(", ") : "none"}`, @@ -160,6 +161,7 @@ function makeThrowingActivity( return async (input: unknown) => { const inputResult = await activityDef.input["~standard"].validate(input); if (inputResult.issues) { + // oxlint-disable-next-line unthrown/no-throw -- sanctioned ValidationError/ApplicationFailure model: terminal failure Temporal must see thrown (CLAUDE.md rule 2 exception) throw new ActivityInputValidationError(activityName, inputResult.issues); } @@ -168,6 +170,7 @@ function makeThrowingActivity( const outputResult = await activityDef.output["~standard"].validate(result); if (outputResult.issues) { + // oxlint-disable-next-line unthrown/no-throw -- sanctioned ValidationError/ApplicationFailure model: terminal failure Temporal must see thrown (CLAUDE.md rule 2 exception) throw new ActivityOutputValidationError(activityName, outputResult.issues); } diff --git a/packages/worker/src/activity.ts b/packages/worker/src/activity.ts index ed963a44..8c130447 100644 --- a/packages/worker/src/activity.ts +++ b/packages/worker/src/activity.ts @@ -723,6 +723,7 @@ export function declareActivitiesHandler< // schema transform) happens exactly once, here. const inputResult = await activityDef.input["~standard"].validate(input); if (inputResult.issues) { + // oxlint-disable-next-line unthrown/no-throw -- sanctioned ValidationError/ApplicationFailure model: terminal failure Temporal must see thrown (CLAUDE.md rule 2 exception) throw new ActivityInputValidationError(label, inputResult.issues); } @@ -759,6 +760,7 @@ export function declareActivitiesHandler< return makeAsyncResult(async () => { const revalidated = await activityDef.input["~standard"].validate(substituted); if (revalidated.issues) { + // oxlint-disable-next-line unthrown/no-throw -- sanctioned ValidationError/ApplicationFailure model: thrown inside the makeAsyncResult work thunk so Temporal sees the terminal failure (CLAUDE.md rule 2 exception) throw new ActivityInputValidationError(label, revalidated.issues); } return await invokeImplementation(revalidated.value, nextContext); @@ -785,6 +787,7 @@ export function declareActivitiesHandler< // applied exactly once, on receive. const outputResult = await activityDef.output["~standard"].validate(value); if (outputResult.issues) { + // oxlint-disable-next-line unthrown/no-throw -- sanctioned ValidationError/ApplicationFailure model: terminal failure Temporal must see thrown (CLAUDE.md rule 2 exception) throw new ActivityOutputValidationError(label, outputResult.issues); } return value; @@ -800,12 +803,14 @@ export function declareActivitiesHandler< P.tag("@temporal-contract/ContractError"), async (error) => { if (error instanceof ContractError) { + // oxlint-disable-next-line unthrown/no-throw -- sanctioned ApplicationFailure model: the Err payload is thrown at the activity boundary so Temporal applies its retry policy (CLAUDE.md rule 2 exception) throw await contractErrorToApplicationFailure( error, activityDef.errors, `activity "${label}"`, ); } + // oxlint-disable-next-line unthrown/no-throw -- sanctioned ApplicationFailure model: the Err payload is thrown at the activity boundary so Temporal applies its retry policy (CLAUDE.md rule 2 exception) throw error; }, ), @@ -820,6 +825,7 @@ export function declareActivitiesHandler< // permanent failure should return `Err(ApplicationFailure.create({ // nonRetryable: true }))` explicitly. defect: (cause) => { + // oxlint-disable-next-line unthrown/no-throw -- defect-channel edge: re-throw the unmodeled cause unwrapped so Temporal's default (retryable) handling applies throw cause; }, }); @@ -842,6 +848,7 @@ export function declareActivitiesHandler< if (contract.activities) { for (const activityName of Object.keys(contract.activities)) { if (Object.hasOwn(workflowDefs, activityName)) { + // oxlint-disable-next-line unthrown/no-throw -- declaration-time fail-fast config error: worker startup must abort on an ambiguous implementations map throw new Error( `global activity "${activityName}" has the same name as a workflow. Workflows and global activities share the root of the worker implementations map — rename one of them.`, ); @@ -900,6 +907,7 @@ export function declareActivitiesHandler< if (wfActivitiesImpl) { for (const activityName of Object.keys(wfActivitiesImpl)) { if (!Object.hasOwn(wfDefs, activityName)) { + // oxlint-disable-next-line unthrown/no-throw -- declaration-time fail-fast config error: worker startup must abort on an implementation for an undeclared activity throw new ActivityDefinitionNotFoundError( `${workflowName}.${activityName}`, Object.keys(wfDefs), @@ -910,6 +918,7 @@ export function declareActivitiesHandler< } if (missingImplementations.length > 0) { + // oxlint-disable-next-line unthrown/no-throw -- declaration-time fail-fast config error: worker startup must abort on missing activity implementations throw new Error( `declareActivitiesHandler: missing implementation${missingImplementations.length > 1 ? "s" : ""} ` + `for declared activit${missingImplementations.length > 1 ? "ies" : "y"}: ` + @@ -924,6 +933,7 @@ export function declareActivitiesHandler< for (const key of Object.keys(implementationMap)) { if (Object.hasOwn(workflowDefs, key)) continue; // workflow namespace, validated above if (contract.activities && Object.hasOwn(contract.activities, key)) continue; + // oxlint-disable-next-line unthrown/no-throw -- declaration-time fail-fast config error: worker startup must abort on a stray implementation key throw new ActivityDefinitionNotFoundError(key, Object.keys(contract.activities ?? {})); } diff --git a/packages/worker/src/cancellation.ts b/packages/worker/src/cancellation.ts index 22069685..77617b35 100644 --- a/packages/worker/src/cancellation.ts +++ b/packages/worker/src/cancellation.ts @@ -65,6 +65,7 @@ export function cancellableScope( } // Non-cancellation throw → re-throw so `makeAsyncResult`'s boundary // routes it through the `defect` channel as an unmodeled failure. + // oxlint-disable-next-line unthrown/no-throw -- cancellation-scope rethrow: the throw IS the defect-channel routing through makeAsyncResult's boundary throw error; } }; @@ -100,6 +101,7 @@ export function nonCancellableScope( if (isCancellation(error)) { return Err(new WorkflowCancelledError(error)); } + // oxlint-disable-next-line unthrown/no-throw -- cancellation-scope rethrow: the throw IS the defect-channel routing through makeAsyncResult's boundary throw error; } }; diff --git a/packages/worker/src/contract-errors.ts b/packages/worker/src/contract-errors.ts index 5b7bdf29..353150c2 100644 --- a/packages/worker/src/contract-errors.ts +++ b/packages/worker/src/contract-errors.ts @@ -37,6 +37,7 @@ export async function contractErrorToApplicationFailure( ): Promise { const definition = declaredErrors?.[error.errorName]; if (!definition) { + // oxlint-disable-next-line unthrown/no-throw -- sanctioned ValidationError/ApplicationFailure model: terminal failure Temporal must see thrown (CLAUDE.md rule 2 exception) throw new ContractErrorDataValidationError(error.errorName, [ { message: @@ -50,6 +51,7 @@ export async function contractErrorToApplicationFailure( if (definition.data) { const validated = await definition.data["~standard"].validate(error.data); if (validated.issues) { + // oxlint-disable-next-line unthrown/no-throw -- sanctioned ValidationError/ApplicationFailure model: terminal failure Temporal must see thrown (CLAUDE.md rule 2 exception) throw new ContractErrorDataValidationError(error.errorName, validated.issues); } // Transmit the ORIGINAL payload — the rehydrating side parses it (D1). diff --git a/packages/worker/src/handlers.ts b/packages/worker/src/handlers.ts index bd903051..485d7f65 100644 --- a/packages/worker/src/handlers.ts +++ b/packages/worker/src/handlers.ts @@ -99,12 +99,14 @@ export function bindSignalHandler( handler: SignalHandlerImplementation, ): void { if (!workflowDefinition.signals) { + // oxlint-disable-next-line unthrown/no-throw -- sanctioned ContractMisuseError model: non-retryable ApplicationFailure Temporal must see thrown (CLAUDE.md rule 2 exception) throw new ContractMisuseError( `Signal "${signalName}" cannot be defined: workflow "${workflowName}" has no signals in its contract`, ); } const signalDef = (workflowDefinition.signals as Record)[signalName]; if (!signalDef) { + // oxlint-disable-next-line unthrown/no-throw -- sanctioned ContractMisuseError model: non-retryable ApplicationFailure Temporal must see thrown (CLAUDE.md rule 2 exception) throw new ContractMisuseError( `Signal "${signalName}" not found in workflow "${workflowName}" contract`, ); @@ -148,12 +150,14 @@ export function bindQueryHandler( handler: QueryHandlerImplementation, ): void { if (!workflowDefinition.queries) { + // oxlint-disable-next-line unthrown/no-throw -- sanctioned ContractMisuseError model: non-retryable ApplicationFailure Temporal must see thrown (CLAUDE.md rule 2 exception) throw new ContractMisuseError( `Query "${queryName}" cannot be defined: workflow "${workflowName}" has no queries in its contract`, ); } const queryDef = (workflowDefinition.queries as Record)[queryName]; if (!queryDef) { + // oxlint-disable-next-line unthrown/no-throw -- sanctioned ContractMisuseError model: non-retryable ApplicationFailure Temporal must see thrown (CLAUDE.md rule 2 exception) throw new ContractMisuseError( `Query "${queryName}" not found in workflow "${workflowName}" contract`, ); @@ -165,11 +169,13 @@ export function bindQueryHandler( const inputResult = queryDef.input["~standard"].validate(input); if (inputResult instanceof Promise) { + // oxlint-disable-next-line unthrown/no-throw -- sanctioned ContractMisuseError model: non-retryable ApplicationFailure Temporal must see thrown (CLAUDE.md rule 2 exception) throw new ContractMisuseError( `Query "${queryName}" validation must be synchronous. Use a schema library that supports synchronous validation for queries.`, ); } if (inputResult.issues) { + // oxlint-disable-next-line unthrown/no-throw -- sanctioned ValidationError/ApplicationFailure model: terminal failure Temporal must see thrown (CLAUDE.md rule 2 exception) throw new QueryInputValidationError(queryName, inputResult.issues); } @@ -177,11 +183,13 @@ export function bindQueryHandler( const outputResult = queryDef.output["~standard"].validate(result); if (outputResult instanceof Promise) { + // oxlint-disable-next-line unthrown/no-throw -- sanctioned ContractMisuseError model: non-retryable ApplicationFailure Temporal must see thrown (CLAUDE.md rule 2 exception) throw new ContractMisuseError( `Query "${queryName}" output validation must be synchronous. Use a schema library that supports synchronous validation for queries.`, ); } if (outputResult.issues) { + // oxlint-disable-next-line unthrown/no-throw -- sanctioned ValidationError/ApplicationFailure model: terminal failure Temporal must see thrown (CLAUDE.md rule 2 exception) throw new QueryOutputValidationError(queryName, outputResult.issues); } @@ -228,12 +236,14 @@ export function bindUpdateHandler( handler: UpdateHandlerImplementation, ): void { if (!workflowDefinition.updates) { + // oxlint-disable-next-line unthrown/no-throw -- sanctioned ContractMisuseError model: non-retryable ApplicationFailure Temporal must see thrown (CLAUDE.md rule 2 exception) throw new ContractMisuseError( `Update "${updateName}" cannot be defined: workflow "${workflowName}" has no updates in its contract`, ); } const updateDef = (workflowDefinition.updates as Record)[updateName]; if (!updateDef) { + // oxlint-disable-next-line unthrown/no-throw -- sanctioned ContractMisuseError model: non-retryable ApplicationFailure Temporal must see thrown (CLAUDE.md rule 2 exception) throw new ContractMisuseError( `Update "${updateName}" not found in workflow "${workflowName}" contract`, ); @@ -255,6 +265,7 @@ export function bindUpdateHandler( const input = extractHandlerInput(args); const inputResult = updateDef.input["~standard"].validate(input); if (inputResult instanceof Promise) { + // oxlint-disable-next-line unthrown/no-throw -- sanctioned ContractMisuseError model: non-retryable ApplicationFailure Temporal must see thrown (CLAUDE.md rule 2 exception) throw new ContractMisuseError(updateInputMustBeSynchronousMessage(updateName)); } if (inputResult.issues) { @@ -262,6 +273,7 @@ export function bindUpdateHandler( // schema produced different issues on a second call (non-pure // validator). Surface it as the same typed error class for // consistency. + // oxlint-disable-next-line unthrown/no-throw -- sanctioned ValidationError/ApplicationFailure model: terminal failure Temporal must see thrown (CLAUDE.md rule 2 exception) throw new UpdateInputValidationError(updateName, inputResult.issues); } @@ -269,6 +281,7 @@ export function bindUpdateHandler( const outputResult = await updateDef.output["~standard"].validate(result); if (outputResult.issues) { + // oxlint-disable-next-line unthrown/no-throw -- sanctioned ValidationError/ApplicationFailure model: terminal failure Temporal must see thrown (CLAUDE.md rule 2 exception) throw new UpdateOutputValidationError(updateName, outputResult.issues); } @@ -282,9 +295,11 @@ export function bindUpdateHandler( const inputResult = updateDef.input["~standard"].validate(input); if (inputResult instanceof Promise) { + // oxlint-disable-next-line unthrown/no-throw -- sanctioned ContractMisuseError model: non-retryable ApplicationFailure Temporal must see thrown (CLAUDE.md rule 2 exception) throw new ContractMisuseError(updateInputMustBeSynchronousMessage(updateName)); } if (inputResult.issues) { + // oxlint-disable-next-line unthrown/no-throw -- sanctioned ValidationError/ApplicationFailure model: terminal failure Temporal must see thrown (CLAUDE.md rule 2 exception) throw new UpdateInputValidationError(updateName, inputResult.issues); } }, diff --git a/packages/worker/src/internal.ts b/packages/worker/src/internal.ts index 8f24202e..bed30b55 100644 --- a/packages/worker/src/internal.ts +++ b/packages/worker/src/internal.ts @@ -135,6 +135,7 @@ export function buildRawActivitiesProxy( // ContractMisuseError (a non-retryable ApplicationFailure), not a plain // Error: this runs inside the workflow sandbox, where a plain Error // would be retried as a Workflow Task failure forever (D3). + // oxlint-disable-next-line unthrown/no-throw -- sanctioned ContractMisuseError model: declaration-time fail-fast as a non-retryable ApplicationFailure (CLAUDE.md rule 2 exception) throw new ContractMisuseError( `declareWorkflow: \`activityOptions\` was omitted but the following activities declare ` + `no contract-level \`defaultOptions\` and have no \`activityOptionsByName\` entry: ` + @@ -160,6 +161,7 @@ export function buildRawActivitiesProxy( : []; for (const [name] of overrideEntries) { if (!(name in allDefinitions)) { + // oxlint-disable-next-line unthrown/no-throw -- sanctioned ContractMisuseError model: declaration-time fail-fast as a non-retryable ApplicationFailure (CLAUDE.md rule 2 exception) throw new ContractMisuseError( `activityOptionsByName entry "${name}" does not match any declared activity. Available: ${Object.keys(allDefinitions).join(", ") || "none"}.`, ); @@ -285,6 +287,7 @@ export function createContinueAsNew( const targetDef = targetContract.workflows[targetName]; if (!targetDef) { + // oxlint-disable-next-line unthrown/no-throw -- sanctioned ValidationError/ApplicationFailure model: terminal failure Temporal must see thrown (CLAUDE.md rule 2 exception) throw new WorkflowInputValidationError(targetName, [ { message: `continueAsNew target workflow "${targetName}" is not declared on the supplied contract.`, @@ -294,6 +297,7 @@ export function createContinueAsNew( const inputResult = await targetDef.input["~standard"].validate(rawArgs); if (inputResult.issues) { + // oxlint-disable-next-line unthrown/no-throw -- sanctioned ValidationError/ApplicationFailure model: terminal failure Temporal must see thrown (CLAUDE.md rule 2 exception) throw new WorkflowInputValidationError(targetName, inputResult.issues); } diff --git a/packages/worker/src/workflow.ts b/packages/worker/src/workflow.ts index e8312eca..562ff82c 100644 --- a/packages/worker/src/workflow.ts +++ b/packages/worker/src/workflow.ts @@ -205,6 +205,7 @@ export function declareWorkflow< | undefined; if (!definition) { const available = Object.keys(contract.workflows).join(", ") || "none"; + // oxlint-disable-next-line unthrown/no-throw -- sanctioned ContractMisuseError model: declaration-time fail-fast as a non-retryable ApplicationFailure (CLAUDE.md rule 2 exception) throw new ContractMisuseError( `declareWorkflow: workflow "${String(workflowName)}" is not declared on the supplied contract. Available workflows: ${available}`, ); @@ -285,6 +286,7 @@ export function declareWorkflow< // happens exactly once, here. const inputResult = await definition.input["~standard"].validate(input); if (inputResult.issues) { + // oxlint-disable-next-line unthrown/no-throw -- sanctioned ValidationError/ApplicationFailure model: terminal failure Temporal must see thrown (CLAUDE.md rule 2 exception) throw new WorkflowInputValidationError(workflowName, inputResult.issues); } const validatedInput = inputResult.value as WorkerInferInput< @@ -357,12 +359,14 @@ export function declareWorkflow< result = await implementation(context, validatedInput); } catch (error) { if (error instanceof ContractError) { + // oxlint-disable-next-line unthrown/no-throw -- sanctioned ApplicationFailure model: a declared contract error fails the execution terminally with its typed wire shape (CLAUDE.md rule 2 exception) throw await contractErrorToApplicationFailure( error, definition.errors, `workflow "${workflowName}"`, ); } + // oxlint-disable-next-line unthrown/no-throw -- workflow-sandbox rethrow: an unmodeled implementation throw must reach Temporal untouched throw error; } @@ -373,6 +377,7 @@ export function declareWorkflow< // transforming output schema is applied exactly once, on receive. const outputResult = await definition.output["~standard"].validate(result); if (outputResult.issues) { + // oxlint-disable-next-line unthrown/no-throw -- sanctioned ValidationError/ApplicationFailure model: terminal failure Temporal must see thrown (CLAUDE.md rule 2 exception) throw new WorkflowOutputValidationError(workflowName, outputResult.issues); } From a3552164906652746a3d69119cb8b01bd5db76f0 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 30 Jul 2026 10:32:41 +0200 Subject: [PATCH 3/3] chore: add changeset for the ClientInfer* error-union precision fix Co-Authored-By: Claude Fable 5 --- .changeset/enable-all-unthrown-oxlint-rules.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/enable-all-unthrown-oxlint-rules.md diff --git a/.changeset/enable-all-unthrown-oxlint-rules.md b/.changeset/enable-all-unthrown-oxlint-rules.md new file mode 100644 index 00000000..6b708eea --- /dev/null +++ b/.changeset/enable-all-unthrown-oxlint-rules.md @@ -0,0 +1,5 @@ +--- +"@temporal-contract/client": patch +--- + +The exported `ClientInferSignal` / `ClientInferQuery` / `ClientInferUpdate` aliases now carry the concrete error unions the handle proxies actually produce (`{Signal,Query,Update}ValidationError | WorkflowExecutionNotFoundError`) instead of a bare, unmatchable `Error` channel — surfaced by adopting `@unthrown/oxlint`'s `no-ambiguous-error-type` rule. `TypedWorkflowHandle` behavior is unchanged (it already substituted the concrete unions); only direct consumers of the aliases see the more precise types. The rest of the adoption (all six rules at `error`, reasoned `no-throw` disables at the sanctioned throw sites) is comment-and-config only.