Skip to content

Commit 1d4cbdf

Browse files
authored
Support both new decorators (typescript 5.0+) and legacy decorators (#120)
* Support both new decorators (typescript 5.0+) and legacy decorators * Update schema decorators and fix lint issue * Fix types
1 parent f6fd6d4 commit 1d4cbdf

6 files changed

Lines changed: 399 additions & 38 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);

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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: 75 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -208,28 +208,75 @@ export interface WorkerOptions {
208208
* }
209209
* ```
210210
*/
211+
/** Minimal context shape for Stage 3 method decorators (TypeScript 5.0+). */
212+
interface MethodDecoratorContext {
213+
kind: string;
214+
name: string | symbol;
215+
}
216+
217+
/**
218+
* Type guard for Stage 3 (TypeScript 5.0+) decorator context.
219+
* New decorators pass (value, context) where context has a `kind` property.
220+
*/
221+
function isNewDecoratorContext(arg: unknown): arg is MethodDecoratorContext {
222+
return (
223+
typeof arg === "object" &&
224+
arg !== null &&
225+
"kind" in arg &&
226+
typeof (arg as { kind: string }).kind === "string"
227+
);
228+
}
229+
230+
type WorkerMethod = (
231+
task: Task
232+
) => Promise<Omit<TaskResult, "workflowInstanceId" | "taskId">>;
233+
211234
export function worker(options: WorkerOptions) {
212-
return function (
213-
target: unknown,
235+
function decorator<T extends WorkerMethod>(
236+
value: T,
237+
context: MethodDecoratorContext
238+
): T | undefined;
239+
function decorator(
240+
target: object,
214241
propertyKey?: string,
215242
descriptor?: PropertyDescriptor
216-
) {
217-
// Extract the function to register
218-
const executeFunction = descriptor?.value || target;
243+
): PropertyDescriptor | WorkerMethod | undefined;
244+
function decorator<T extends WorkerMethod>(
245+
target: T | object,
246+
propertyKeyOrContext?: string | MethodDecoratorContext,
247+
descriptor?: PropertyDescriptor
248+
): T | PropertyDescriptor | undefined {
249+
// Detect decorator API: new (Stage 3) vs legacy (experimentalDecorators)
250+
let executeFunction: (task: Task) => Promise<Omit<TaskResult, "workflowInstanceId" | "taskId">>;
251+
let isNewApi = false;
252+
253+
if (isNewDecoratorContext(propertyKeyOrContext)) {
254+
// New decorator API: target is the method itself
255+
executeFunction = target as (task: Task) => Promise<
256+
Omit<TaskResult, "workflowInstanceId" | "taskId">
257+
>;
258+
isNewApi = true;
259+
} else {
260+
// Legacy API: descriptor?.value (method) or target (standalone function)
261+
const fn = (descriptor?.value ?? target) as (
262+
task: Task
263+
) => Promise<Omit<TaskResult, "workflowInstanceId" | "taskId">>;
264+
executeFunction = fn;
265+
}
219266

220267
// Validate that we have a function
221268
if (typeof executeFunction !== "function") {
222269
throw new Error(
223270
`@worker decorator can only be applied to functions. ` +
224-
`Received: ${typeof executeFunction}`
271+
`Received: ${typeof executeFunction}`
225272
);
226273
}
227274

228275
// Validate required options
229276
if (!options.taskDefName) {
230277
throw new Error(
231278
`@worker decorator requires 'taskDefName' option. ` +
232-
`Example: @worker({ taskDefName: "my_task" })`
279+
`Example: @worker({ taskDefName: "my_task" })`
233280
);
234281
}
235282

@@ -238,16 +285,20 @@ export function worker(options: WorkerOptions) {
238285
let resolvedOutputSchema = options.outputSchema;
239286

240287
if (options.inputType) {
241-
resolvedInputSchema = generateSchemaFromClass(options.inputType) as unknown as Record<string, unknown>;
288+
resolvedInputSchema = generateSchemaFromClass(
289+
options.inputType
290+
) as unknown as Record<string, unknown>;
242291
}
243292
if (options.outputType) {
244-
resolvedOutputSchema = generateSchemaFromClass(options.outputType) as unknown as Record<string, unknown>;
293+
resolvedOutputSchema = generateSchemaFromClass(
294+
options.outputType
295+
) as unknown as Record<string, unknown>;
245296
}
246297

247298
// Create registered worker metadata
248299
const registeredWorker: RegisteredWorker = {
249300
taskDefName: options.taskDefName,
250-
executeFunction: executeFunction as (task: Task) => Promise<Omit<TaskResult, "workflowInstanceId" | "taskId">>,
301+
executeFunction,
251302
concurrency: options.concurrency,
252303
pollInterval: options.pollInterval,
253304
domain: options.domain,
@@ -285,7 +336,10 @@ export function worker(options: WorkerOptions) {
285336
);
286337
}
287338
// Normal execution mode
288-
return (executeFunction as (...args: unknown[]) => unknown).apply(this, args);
339+
return (executeFunction as (...args: unknown[]) => unknown).apply(
340+
this,
341+
args
342+
);
289343
};
290344

291345
// Preserve original function name
@@ -294,10 +348,18 @@ export function worker(options: WorkerOptions) {
294348
configurable: true,
295349
});
296350

351+
if (isNewApi) {
352+
// New decorator API: return replacement function
353+
return dualModeFunction as unknown as T;
354+
}
355+
356+
// Legacy API
297357
if (descriptor) {
298358
descriptor.value = dualModeFunction;
299359
return descriptor;
300360
}
301-
return dualModeFunction;
302-
};
361+
return dualModeFunction as unknown as T;
362+
}
363+
364+
return decorator;
303365
}

0 commit comments

Comments
 (0)