Skip to content

Commit 9be2ed4

Browse files
committed
Support both new decorators (typescript 5.0+) and legacy decorators
1 parent f6fd6d4 commit 9be2ed4

3 files changed

Lines changed: 216 additions & 19 deletions

File tree

README.md

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ await workflow.register();
7777

7878
**Step 2: Write a worker**
7979

80-
Workers are TypeScript functions decorated with `@worker` that poll Conductor for tasks and execute them.
80+
Workers are TypeScript functions decorated with `@worker` that poll Conductor for tasks and execute them. The example below uses the legacy decorator style (standalone function). See [Workers](#workers) for the new TypeScript 5.0+ decorator style (class methods).
8181

8282
```typescript
8383
import { worker } from "@io-orkes/conductor-javascript";
@@ -215,30 +215,77 @@ All of these are type-safe, composable, and registered to the server as JSON —
215215
216216
## Workers
217217
218-
Workers are TypeScript functions that execute Conductor tasks. Decorate any function with `@worker` to register it as a worker (auto-discovered by `TaskHandler`) and use it as a workflow task.
218+
Workers are TypeScript functions that execute Conductor tasks. Decorate functions with `@worker` to register them as workers (auto-discovered by `TaskHandler`) and use them as workflow tasks.
219+
220+
The SDK supports **both** decorator styles:
221+
222+
### Option 1: New decorators (TypeScript 5.0+)
223+
224+
Use class methods with the new Stage 3 decorators. No `experimentalDecorators` needed — remove it from your `tsconfig.json`.
225+
226+
```typescript
227+
import { worker, TaskHandler } from "@io-orkes/conductor-javascript";
228+
import type { Task } from "@io-orkes/conductor-javascript";
229+
230+
class Workers {
231+
@worker({ taskDefName: "greet", concurrency: 5, pollInterval: 100 })
232+
async greet(task: Task) {
233+
return {
234+
status: "COMPLETED" as const,
235+
outputData: { result: `Hello ${task.inputData?.name ?? "World"}` },
236+
};
237+
}
238+
239+
@worker({ taskDefName: "process_payment", domain: "payments" })
240+
async processPayment(task: Task) {
241+
const result = await paymentGateway.charge(task.inputData.customerId, task.inputData.amount);
242+
return { status: "COMPLETED" as const, outputData: { transactionId: result.id } };
243+
}
244+
}
245+
246+
// Class definition triggers decorators — workers are registered
247+
void new Workers();
248+
249+
const handler = new TaskHandler({ client, scanForDecorated: true });
250+
await handler.startWorkers();
251+
```
252+
253+
### Option 2: Legacy decorators (experimentalDecorators)
254+
255+
Use standalone functions. Add `"experimentalDecorators": true` to your `tsconfig.json`.
219256
220257
```typescript
221258
import { worker, TaskHandler } from "@io-orkes/conductor-javascript";
259+
import type { Task } from "@io-orkes/conductor-javascript";
222260
223261
@worker({ taskDefName: "greet", concurrency: 5, pollInterval: 100 })
224262
async function greet(task: Task) {
225263
return {
226-
status: "COMPLETED",
227-
outputData: { result: `Hello ${task.inputData.name}` },
264+
status: "COMPLETED" as const,
265+
outputData: { result: `Hello ${task.inputData?.name ?? "World"}` },
228266
};
229267
}
230268
231269
@worker({ taskDefName: "process_payment", domain: "payments" })
232270
async function processPayment(task: Task) {
233271
const result = await paymentGateway.charge(task.inputData.customerId, task.inputData.amount);
234-
return { status: "COMPLETED", outputData: { transactionId: result.id } };
272+
return { status: "COMPLETED" as const, outputData: { transactionId: result.id } };
235273
}
236274
237-
// Auto-discover and start all decorated workers
238275
const handler = new TaskHandler({ client, scanForDecorated: true });
239276
await handler.startWorkers();
277+
```
278+
279+
### tsconfig setup
240280
241-
// Graceful shutdown
281+
| Decorator style | tsconfig.json |
282+
|-----------------|---------------|
283+
| **New** (TypeScript 5.0+) | Omit `experimentalDecorators` — use class methods |
284+
| **Legacy** | `"experimentalDecorators": true` — use standalone functions |
285+
286+
**Graceful shutdown:**
287+
288+
```typescript
242289
process.on("SIGTERM", async () => {
243290
await handler.stopWorkers();
244291
process.exit(0);

src/sdk/worker/decorators/__tests__/worker.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,111 @@ describe("@worker decorator", () => {
220220
});
221221
});
222222

223+
describe("@worker decorator - New API (TypeScript 5.0+ Stage 3 decorators)", () => {
224+
beforeEach(() => {
225+
clearWorkerRegistry();
226+
});
227+
228+
afterEach(() => {
229+
clearWorkerRegistry();
230+
});
231+
232+
test("should register when called with new decorator signature (value, context)", () => {
233+
async function greetMethod(task: Task) {
234+
return {
235+
status: "COMPLETED" as const,
236+
outputData: { result: `Hello ${(task.inputData as Record<string, string>)?.name ?? "World"}` },
237+
};
238+
}
239+
240+
// Simulate new decorator API: decorator(value, context) where context has kind
241+
const decorator = worker({ taskDefName: "new_api_greet" });
242+
decorator(greetMethod, { kind: "method", name: "greet" });
243+
244+
const workers = getRegisteredWorkers();
245+
expect(workers).toHaveLength(1);
246+
expect(workers[0].taskDefName).toBe("new_api_greet");
247+
expect(workers[0].executeFunction).toBe(greetMethod);
248+
});
249+
250+
test("should return replacement function for new API (replaces class method)", () => {
251+
async function originalMethod(task: Task) {
252+
return {
253+
status: "COMPLETED" as const,
254+
outputData: { value: (task.inputData as Record<string, number>).x + 1 },
255+
};
256+
}
257+
258+
const decorator = worker({ taskDefName: "new_api_replace" });
259+
const replacement = decorator(originalMethod, { kind: "method", name: "compute" });
260+
261+
expect(typeof replacement).toBe("function");
262+
expect(replacement).not.toBe(originalMethod);
263+
264+
// Replacement should execute the original when called normally
265+
const result = (replacement as (task: Task) => Promise<{ status: string; outputData: unknown }>)(
266+
{ inputData: { x: 10 } } as Task
267+
);
268+
return expect(result).resolves.toEqual({
269+
status: "COMPLETED",
270+
outputData: { value: 11 },
271+
});
272+
});
273+
274+
test("should support dual-mode (workflow builder) when using new API", () => {
275+
async function processTask(_task: Task) {
276+
return { status: "COMPLETED" as const, outputData: { done: true } };
277+
}
278+
279+
const decorator = worker({ taskDefName: "new_api_dual" });
280+
const replacement = decorator(processTask, { kind: "method", name: "process" }) as (
281+
arg: { taskRefName: string; inputParameters?: Record<string, unknown> }
282+
) => unknown;
283+
284+
const taskDef = replacement({
285+
taskRefName: "step_1",
286+
inputParameters: { key: "value" },
287+
});
288+
289+
expect(taskDef).toMatchObject({
290+
name: "new_api_dual",
291+
taskReferenceName: "step_1",
292+
inputParameters: { key: "value" },
293+
});
294+
});
295+
296+
test("should register with options when using new API", () => {
297+
async function workerFn(_task: Task) {
298+
return { status: "COMPLETED" as const, outputData: {} };
299+
}
300+
301+
const decorator = worker({
302+
taskDefName: "new_api_options",
303+
concurrency: 5,
304+
pollInterval: 300,
305+
domain: "staging",
306+
});
307+
decorator(workerFn, { kind: "method", name: "workerFn" });
308+
309+
const registered = getRegisteredWorker("new_api_options", "staging");
310+
expect(registered).toBeDefined();
311+
expect(registered?.concurrency).toBe(5);
312+
expect(registered?.pollInterval).toBe(300);
313+
expect(registered?.domain).toBe("staging");
314+
});
315+
316+
test("should throw if taskDefName missing with new API", () => {
317+
async function fn(_task: Task) {
318+
return { status: "COMPLETED" as const, outputData: {} };
319+
}
320+
321+
const decorator = worker({} as { taskDefName: string });
322+
expect(() => {
323+
decorator(fn, { kind: "method", name: "fn" });
324+
}).toThrow("requires 'taskDefName'");
325+
});
326+
});
327+
223328
describe("Worker Registry", () => {
224329
beforeEach(() => {
225330
clearWorkerRegistry();

src/sdk/worker/decorators/worker.ts

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -208,28 +208,60 @@ export interface WorkerOptions {
208208
* }
209209
* ```
210210
*/
211+
/**
212+
* Type guard for Stage 3 (TypeScript 5.0+) decorator context.
213+
* New decorators pass (value, context) where context has a `kind` property.
214+
*/
215+
function isNewDecoratorContext(
216+
arg: unknown
217+
): arg is { kind: string; name: string | symbol } {
218+
return (
219+
typeof arg === "object" &&
220+
arg !== null &&
221+
"kind" in arg &&
222+
typeof (arg as { kind: string }).kind === "string"
223+
);
224+
}
225+
211226
export function worker(options: WorkerOptions) {
212-
return function (
213-
target: unknown,
214-
propertyKey?: string,
227+
return function <T extends (task: Task) => Promise<Omit<TaskResult, "workflowInstanceId" | "taskId">>>(
228+
target: T,
229+
propertyKeyOrContext?:
230+
| string
231+
| { kind: string; name: string | symbol },
215232
descriptor?: PropertyDescriptor
216-
) {
217-
// Extract the function to register
218-
const executeFunction = descriptor?.value || target;
233+
): T | PropertyDescriptor | void {
234+
// Detect decorator API: new (Stage 3) vs legacy (experimentalDecorators)
235+
let executeFunction: (task: Task) => Promise<Omit<TaskResult, "workflowInstanceId" | "taskId">>;
236+
let isNewApi = false;
237+
238+
if (isNewDecoratorContext(propertyKeyOrContext)) {
239+
// New decorator API: target is the method itself
240+
executeFunction = target as (task: Task) => Promise<
241+
Omit<TaskResult, "workflowInstanceId" | "taskId">
242+
>;
243+
isNewApi = true;
244+
} else {
245+
// Legacy API: descriptor?.value (method) or target (standalone function)
246+
const fn = (descriptor?.value ?? target) as (
247+
task: Task
248+
) => Promise<Omit<TaskResult, "workflowInstanceId" | "taskId">>;
249+
executeFunction = fn;
250+
}
219251

220252
// Validate that we have a function
221253
if (typeof executeFunction !== "function") {
222254
throw new Error(
223255
`@worker decorator can only be applied to functions. ` +
224-
`Received: ${typeof executeFunction}`
256+
`Received: ${typeof executeFunction}`
225257
);
226258
}
227259

228260
// Validate required options
229261
if (!options.taskDefName) {
230262
throw new Error(
231263
`@worker decorator requires 'taskDefName' option. ` +
232-
`Example: @worker({ taskDefName: "my_task" })`
264+
`Example: @worker({ taskDefName: "my_task" })`
233265
);
234266
}
235267

@@ -238,16 +270,20 @@ export function worker(options: WorkerOptions) {
238270
let resolvedOutputSchema = options.outputSchema;
239271

240272
if (options.inputType) {
241-
resolvedInputSchema = generateSchemaFromClass(options.inputType) as unknown as Record<string, unknown>;
273+
resolvedInputSchema = generateSchemaFromClass(
274+
options.inputType
275+
) as unknown as Record<string, unknown>;
242276
}
243277
if (options.outputType) {
244-
resolvedOutputSchema = generateSchemaFromClass(options.outputType) as unknown as Record<string, unknown>;
278+
resolvedOutputSchema = generateSchemaFromClass(
279+
options.outputType
280+
) as unknown as Record<string, unknown>;
245281
}
246282

247283
// Create registered worker metadata
248284
const registeredWorker: RegisteredWorker = {
249285
taskDefName: options.taskDefName,
250-
executeFunction: executeFunction as (task: Task) => Promise<Omit<TaskResult, "workflowInstanceId" | "taskId">>,
286+
executeFunction,
251287
concurrency: options.concurrency,
252288
pollInterval: options.pollInterval,
253289
domain: options.domain,
@@ -285,7 +321,10 @@ export function worker(options: WorkerOptions) {
285321
);
286322
}
287323
// Normal execution mode
288-
return (executeFunction as (...args: unknown[]) => unknown).apply(this, args);
324+
return (executeFunction as (...args: unknown[]) => unknown).apply(
325+
this,
326+
args
327+
);
289328
};
290329

291330
// Preserve original function name
@@ -294,6 +333,12 @@ export function worker(options: WorkerOptions) {
294333
configurable: true,
295334
});
296335

336+
if (isNewApi) {
337+
// New decorator API: return replacement function (cast to T for type compatibility)
338+
return dualModeFunction as unknown as T;
339+
}
340+
341+
// Legacy API
297342
if (descriptor) {
298343
descriptor.value = dualModeFunction;
299344
return descriptor;

0 commit comments

Comments
 (0)