Skip to content

Commit e5e80b4

Browse files
btraversclaude
andcommitted
chore: adopt @unthrown/oxlint recommended rules
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<infer R, Error>` 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<Result<...>> 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 <noreply@anthropic.com>
1 parent ecb645e commit e5e80b4

13 files changed

Lines changed: 210 additions & 140 deletions

File tree

.oxlintrc.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
11
{
22
"$schema": "./node_modules/oxlint/configuration_schema.json",
3-
"extends": ["./node_modules/@btravstack/oxlint/base.json"]
3+
"extends": ["./node_modules/@btravstack/oxlint/base.json"],
4+
"jsPlugins": [{ "name": "unthrown", "specifier": "@unthrown/oxlint" }],
5+
"rules": {
6+
"unthrown/no-ambiguous-error-type": "error",
7+
"unthrown/no-catch-all-pattern": "error",
8+
"unthrown/no-unhandled-result": "error",
9+
"unthrown/prefer-async-result": "error"
10+
}
411
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"@btravstack/oxlint": "catalog:",
2727
"@changesets/cli": "catalog:",
2828
"@commitlint/cli": "catalog:",
29+
"@unthrown/oxlint": "catalog:",
2930
"knip": "catalog:",
3031
"lefthook": "catalog:",
3132
"oxfmt": "catalog:",

packages/client/src/client.ts

Lines changed: 68 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -296,42 +296,32 @@ export type TypedWorkflowHandle<TWorkflow extends AnyWorkflowDefinition> = {
296296
readonly firstExecutionRunId: string | undefined;
297297

298298
/**
299-
* Type-safe queries based on workflow definition with Result pattern
300-
* Each query returns AsyncResult<T, Error> instead of Promise<T>
299+
* Type-safe queries based on workflow definition with Result pattern.
300+
* Each query returns
301+
* `AsyncResult<T, QueryValidationError | WorkflowExecutionNotFoundError>`
302+
* instead of `Promise<T>` — the error union is carried by
303+
* {@link ClientInferWorkflowQueries} directly.
301304
*/
302-
queries: {
303-
[K in keyof ClientInferWorkflowQueries<TWorkflow>]: ClientInferWorkflowQueries<TWorkflow>[K] extends (
304-
...args: infer Args
305-
) => AsyncResult<infer R, Error>
306-
? (...args: Args) => AsyncResult<R, QueryValidationError | WorkflowExecutionNotFoundError>
307-
: never;
308-
};
305+
queries: ClientInferWorkflowQueries<TWorkflow>;
309306

310307
/**
311-
* Type-safe signals based on workflow definition with Result pattern
312-
* Each signal returns AsyncResult<void, Error> instead of Promise<void>
308+
* Type-safe signals based on workflow definition with Result pattern.
309+
* Each signal returns
310+
* `AsyncResult<void, SignalValidationError | WorkflowExecutionNotFoundError>`
311+
* instead of `Promise<void>` — the error union is carried by
312+
* {@link ClientInferWorkflowSignals} directly.
313313
*/
314-
signals: {
315-
[K in keyof ClientInferWorkflowSignals<TWorkflow>]: ClientInferWorkflowSignals<TWorkflow>[K] extends (
316-
...args: infer Args
317-
) => AsyncResult<void, Error>
318-
? (...args: Args) => AsyncResult<void, SignalValidationError | WorkflowExecutionNotFoundError>
319-
: never;
320-
};
314+
signals: ClientInferWorkflowSignals<TWorkflow>;
321315

322316
/**
323317
* Type-safe updates based on workflow definition with Result pattern.
324318
* Each update starts the update AND waits for its result (Temporal's
325-
* `executeUpdate`); use {@link startUpdate} to obtain an update handle
326-
* without waiting for completion.
319+
* `executeUpdate`), returning
320+
* `AsyncResult<T, UpdateValidationError | WorkflowExecutionNotFoundError>`;
321+
* use {@link startUpdate} to obtain an update handle without waiting for
322+
* completion.
327323
*/
328-
updates: {
329-
[K in keyof ClientInferWorkflowUpdates<TWorkflow>]: ClientInferWorkflowUpdates<TWorkflow>[K] extends (
330-
...args: infer Args
331-
) => AsyncResult<infer R, Error>
332-
? (...args: Args) => AsyncResult<R, UpdateValidationError | WorkflowExecutionNotFoundError>
333-
: never;
334-
};
324+
updates: ClientInferWorkflowUpdates<TWorkflow>;
335325

336326
/**
337327
* Start an update without waiting for its completion — Temporal's
@@ -433,7 +423,7 @@ type ResolvedWorkflow<TWorkflow extends AnyWorkflowDefinition> = {
433423
* extended error classification) stay at the call site — those are the
434424
* differentiators that make each method distinct.
435425
*/
436-
async function resolveDefinitionAndValidateInput<
426+
function resolveDefinitionAndValidateInput<
437427
TContract extends ContractDefinition,
438428
TWorkflowName extends keyof TContract["workflows"] & string,
439429
>(
@@ -442,31 +432,40 @@ async function resolveDefinitionAndValidateInput<
442432
workflowId: string,
443433
args: unknown,
444434
searchAttributes: Record<string, unknown> | undefined,
445-
): Promise<
446-
Result<
435+
): AsyncResult<
436+
ResolvedWorkflow<TContract["workflows"][TWorkflowName]>,
437+
WorkflowNotInContractError | WorkflowValidationError
438+
> {
439+
return makeAsyncResult<
447440
ResolvedWorkflow<TContract["workflows"][TWorkflowName]>,
448441
WorkflowNotInContractError | WorkflowValidationError
449-
>
450-
> {
451-
const definition = contract.workflows[workflowName];
452-
if (!definition) {
453-
return Err(createWorkflowNotInContractError(workflowName, contract));
454-
}
442+
>(async () => {
443+
const definition = contract.workflows[workflowName];
444+
if (!definition) {
445+
return Err(createWorkflowNotInContractError(workflowName, contract));
446+
}
455447

456-
const inputResult = await definition.input["~standard"].validate(args);
457-
if (inputResult.issues) {
458-
return Err(new WorkflowValidationError(workflowName, "input", inputResult.issues, workflowId));
459-
}
448+
const inputResult = await definition.input["~standard"].validate(args);
449+
if (inputResult.issues) {
450+
return Err(
451+
new WorkflowValidationError(workflowName, "input", inputResult.issues, workflowId),
452+
);
453+
}
460454

461-
// `toTypedSearchAttributes` throws a `RuntimeClientError` on an undeclared
462-
// key or a value that doesn't match the declared kind — a technical
463-
// misconfiguration routed to the defect channel by the enclosing
464-
// `makeAsyncResult` work thunk (never a modeled Err).
465-
const typedSearchAttributes = toTypedSearchAttributes(definition, workflowName, searchAttributes);
455+
// `toTypedSearchAttributes` throws a `RuntimeClientError` on an undeclared
456+
// key or a value that doesn't match the declared kind — a technical
457+
// misconfiguration captured as a defect (never a modeled Err) and
458+
// re-thrown into the caller's throw→defect net by `assertNoDefect`.
459+
const typedSearchAttributes = toTypedSearchAttributes(
460+
definition,
461+
workflowName,
462+
searchAttributes,
463+
);
466464

467-
return Ok({
468-
definition: definition as TContract["workflows"][TWorkflowName],
469-
typedSearchAttributes,
465+
return Ok({
466+
definition: definition as TContract["workflows"][TWorkflowName],
467+
typedSearchAttributes,
468+
});
470469
});
471470
}
472471

@@ -552,7 +551,7 @@ export class TypedClient {
552551
* ```
553552
*/
554553
static create({ client, interceptors }: CreateClientOptions): AsyncResult<TypedClient, never> {
555-
const work = async (): Promise<Result<TypedClient, never>> => {
554+
const work = async () => {
556555
// `client.schedule` is the ScheduleClient wired into Temporal's
557556
// top-level `Client` since 1.16. The peer dep allows all of `^1`, so a
558557
// consumer can be on an older version — fail early with a clear
@@ -741,15 +740,16 @@ export class ContractClient<TContract extends ContractDefinition> {
741740
type Ok = TypedWorkflowHandle<TContract["workflows"][TWorkflowName]>;
742741
type Err = WorkflowNotInContractError | WorkflowValidationError | WorkflowAlreadyStartedError;
743742
const runPipeline = (currentInput: unknown): AsyncResult<Ok, Err> => {
744-
const work = async (): Promise<Result<Ok, Err>> => {
743+
const work = async () => {
745744
const resolved = await resolveDefinitionAndValidateInput(
746745
this.contract,
747746
workflowName,
748747
temporalOptions.workflowId,
749748
currentInput,
750749
searchAttributes as Record<string, unknown> | undefined,
751750
);
752-
// The resolver only ever builds ok/err; assert away the impossible defect.
751+
// A technical throw inside the resolver is captured as a defect;
752+
// re-throw it here so it rides this thunk's throw→defect net.
753753
assertNoDefect(resolved);
754754
if (resolved.isErr()) return Err(resolved.error);
755755
const { definition, typedSearchAttributes } = resolved.value;
@@ -778,7 +778,7 @@ export class ContractClient<TContract extends ContractDefinition> {
778778
throw new RuntimeClientError("startWorkflow", error);
779779
}
780780
};
781-
return makeAsyncResult(work);
781+
return makeAsyncResult<Ok, Err>(work);
782782
};
783783

784784
// Interceptors wrap the whole pipeline (outside validation), so a
@@ -869,15 +869,16 @@ export class ContractClient<TContract extends ContractDefinition> {
869869
currentInput: unknown,
870870
currentSignalInput: unknown,
871871
): AsyncResult<Ok, Err> => {
872-
const work = async (): Promise<Result<Ok, Err>> => {
872+
const work = async () => {
873873
const resolved = await resolveDefinitionAndValidateInput(
874874
this.contract,
875875
workflowName,
876876
temporalOptions.workflowId,
877877
currentInput,
878878
searchAttributes as Record<string, unknown> | undefined,
879879
);
880-
// The resolver only ever builds ok/err; assert away the impossible defect.
880+
// A technical throw inside the resolver is captured as a defect;
881+
// re-throw it here so it rides this thunk's throw→defect net.
881882
assertNoDefect(resolved);
882883
if (resolved.isErr()) return Err(resolved.error);
883884
const { definition, typedSearchAttributes } = resolved.value;
@@ -929,7 +930,7 @@ export class ContractClient<TContract extends ContractDefinition> {
929930
throw new RuntimeClientError("signalWithStart", error);
930931
}
931932
};
932-
return makeAsyncResult(work);
933+
return makeAsyncResult<Ok, Err>(work);
933934
};
934935

935936
if (this.interceptors.length === 0) return runPipeline(args, signalArgs);
@@ -1007,15 +1008,16 @@ export class ContractClient<TContract extends ContractDefinition> {
10071008
| WorkflowFailedError
10081009
| WorkflowExecutionNotFoundError;
10091010
const runPipeline = (currentInput: unknown): AsyncResult<Ok, Err> => {
1010-
const work = async (): Promise<Result<Ok, Err>> => {
1011+
const work = async () => {
10111012
const resolved = await resolveDefinitionAndValidateInput(
10121013
this.contract,
10131014
workflowName,
10141015
temporalOptions.workflowId,
10151016
currentInput,
10161017
searchAttributes as Record<string, unknown> | undefined,
10171018
);
1018-
// The resolver only ever builds ok/err; assert away the impossible defect.
1019+
// A technical throw inside the resolver is captured as a defect;
1020+
// re-throw it here so it rides this thunk's throw→defect net.
10191021
assertNoDefect(resolved);
10201022
if (resolved.isErr()) return Err(resolved.error);
10211023
const { definition, typedSearchAttributes } = resolved.value;
@@ -1065,7 +1067,7 @@ export class ContractClient<TContract extends ContractDefinition> {
10651067
throw new RuntimeClientError("executeWorkflow", error);
10661068
}
10671069
};
1068-
return makeAsyncResult(work);
1070+
return makeAsyncResult<Ok, Err>(work);
10691071
};
10701072

10711073
if (this.interceptors.length === 0) return runPipeline(args);
@@ -1194,7 +1196,7 @@ export class ContractClient<TContract extends ContractDefinition> {
11941196
workflowId: updateHandle.workflowId,
11951197
workflowRunId: updateHandle.workflowRunId,
11961198
result: (): AsyncResult<unknown, StartUpdateErr> => {
1197-
const work = async (): Promise<Result<unknown, StartUpdateErr>> => {
1199+
const work = async () => {
11981200
try {
11991201
const raw = await updateHandle.result();
12001202
// Receive side of the update-result boundary: the handler
@@ -1211,7 +1213,7 @@ export class ContractClient<TContract extends ContractDefinition> {
12111213
throw new RuntimeClientError("update.result", error);
12121214
}
12131215
};
1214-
return makeAsyncResult(work);
1216+
return makeAsyncResult<unknown, StartUpdateErr>(work);
12151217
},
12161218
});
12171219

@@ -1220,7 +1222,7 @@ export class ContractClient<TContract extends ContractDefinition> {
12201222
options?: { args?: unknown; updateId?: string; waitForStage?: "ACCEPTED" },
12211223
): AsyncResult<unknown, StartUpdateErr> => {
12221224
const runPipeline = (currentInput: unknown): AsyncResult<unknown, StartUpdateErr> => {
1223-
const work = async (): Promise<Result<unknown, StartUpdateErr>> => {
1225+
const work = async () => {
12241226
const updateDef = (definition.updates as Record<string, UpdateDefinition> | undefined)?.[
12251227
updateName
12261228
];
@@ -1256,7 +1258,7 @@ export class ContractClient<TContract extends ContractDefinition> {
12561258
throw new RuntimeClientError("startUpdate", error);
12571259
}
12581260
};
1259-
return makeAsyncResult(work);
1261+
return makeAsyncResult<unknown, StartUpdateErr>(work);
12601262
};
12611263

12621264
// Interceptors wrap the whole pipeline (outside validation), same
@@ -1296,7 +1298,7 @@ export class ContractClient<TContract extends ContractDefinition> {
12961298
| WorkflowValidationError
12971299
| WorkflowFailedError
12981300
| WorkflowExecutionNotFoundError;
1299-
const work = async (): Promise<Result<Ok, Err>> => {
1301+
const work = async () => {
13001302
try {
13011303
const result = await workflowHandle.result();
13021304
const outputResult = await definition.output["~standard"].validate(result);
@@ -1326,7 +1328,7 @@ export class ContractClient<TContract extends ContractDefinition> {
13261328
throw new RuntimeClientError("result", error);
13271329
}
13281330
};
1329-
return makeAsyncResult(work);
1331+
return makeAsyncResult<Ok, Err>(work);
13301332
},
13311333
terminate: (reason?: string): AsyncResult<void, WorkflowExecutionNotFoundError> =>
13321334
fromPromise(
@@ -1436,7 +1438,7 @@ function buildValidatedProxy<TDef extends DefWithInput, TValidationError extends
14361438

14371439
for (const [name, def] of Object.entries(defs)) {
14381440
const runPipeline = (currentInput: unknown): AsyncResult<unknown, ProxyError> => {
1439-
const work = async (): Promise<Result<unknown, ProxyError>> => {
1441+
const work = async () => {
14401442
const inputResult = await def.input["~standard"].validate(currentInput);
14411443
if (inputResult.issues) {
14421444
return Err(makeValidationError(name, "input", inputResult.issues));
@@ -1461,7 +1463,7 @@ function buildValidatedProxy<TDef extends DefWithInput, TValidationError extends
14611463
throw new RuntimeClientError(operation, error);
14621464
}
14631465
};
1464-
return makeAsyncResult(work);
1466+
return makeAsyncResult<unknown, ProxyError>(work);
14651467
};
14661468

14671469
proxy[name] = (args) => {

packages/client/src/internal.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ export function toTypedSearchAttributes(
159159
* `@temporal-contract/contract` so the same wrapper is shared between the
160160
* client and worker packages.
161161
*/
162+
// 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
162163
export function makeAsyncResult<T, E>(work: () => Promise<Result<T, E>>): AsyncResult<T, E> {
163164
return _internal_makeAsyncResult(work);
164165
}

packages/client/src/schedule.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import type {
1313
ScheduleUpdateOptions,
1414
Workflow,
1515
} from "@temporalio/client";
16-
import { type AsyncResult, type Result, Ok, Err, fromPromise } from "unthrown";
16+
import { type AsyncResult, Ok, Err, fromPromise } from "unthrown";
1717

1818
import type { TypedSearchAttributeMap } from "./client.js";
1919
import {
@@ -185,7 +185,7 @@ export class TypedScheduleClient<TContract extends ContractDefinition> {
185185
> {
186186
type Ok = TypedScheduleHandle;
187187
type Err = WorkflowNotInContractError | WorkflowValidationError | ScheduleAlreadyExistsError;
188-
const work = async (): Promise<Result<Ok, Err>> => {
188+
const work = async () => {
189189
const definition = this.contract.workflows[workflowName];
190190
if (!definition) {
191191
return Err(
@@ -258,7 +258,7 @@ export class TypedScheduleClient<TContract extends ContractDefinition> {
258258
throw new RuntimeClientError("schedule.create", error);
259259
}
260260
};
261-
return makeAsyncResult(work);
261+
return makeAsyncResult<Ok, Err>(work);
262262
}
263263

264264
/**

0 commit comments

Comments
 (0)