From 9be2ed4c9f849f99489bb8ef803d0737dd8dcba9 Mon Sep 17 00:00:00 2001 From: Andrew Lai Date: Thu, 12 Mar 2026 13:55:19 -0700 Subject: [PATCH 1/3] Support both new decorators (typescript 5.0+) and legacy decorators --- README.md | 61 ++++++++-- .../decorators/__tests__/worker.test.ts | 105 ++++++++++++++++++ src/sdk/worker/decorators/worker.ts | 69 ++++++++++-- 3 files changed, 216 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index e4c36c34..7880908a 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ await workflow.register(); **Step 2: Write a worker** -Workers are TypeScript functions decorated with `@worker` that poll Conductor for tasks and execute them. +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). ```typescript 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 — ## Workers -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. +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. + +The SDK supports **both** decorator styles: + +### Option 1: New decorators (TypeScript 5.0+) + +Use class methods with the new Stage 3 decorators. No `experimentalDecorators` needed — remove it from your `tsconfig.json`. + +```typescript +import { worker, TaskHandler } from "@io-orkes/conductor-javascript"; +import type { Task } from "@io-orkes/conductor-javascript"; + +class Workers { + @worker({ taskDefName: "greet", concurrency: 5, pollInterval: 100 }) + async greet(task: Task) { + return { + status: "COMPLETED" as const, + outputData: { result: `Hello ${task.inputData?.name ?? "World"}` }, + }; + } + + @worker({ taskDefName: "process_payment", domain: "payments" }) + async processPayment(task: Task) { + const result = await paymentGateway.charge(task.inputData.customerId, task.inputData.amount); + return { status: "COMPLETED" as const, outputData: { transactionId: result.id } }; + } +} + +// Class definition triggers decorators — workers are registered +void new Workers(); + +const handler = new TaskHandler({ client, scanForDecorated: true }); +await handler.startWorkers(); +``` + +### Option 2: Legacy decorators (experimentalDecorators) + +Use standalone functions. Add `"experimentalDecorators": true` to your `tsconfig.json`. ```typescript import { worker, TaskHandler } from "@io-orkes/conductor-javascript"; +import type { Task } from "@io-orkes/conductor-javascript"; @worker({ taskDefName: "greet", concurrency: 5, pollInterval: 100 }) async function greet(task: Task) { return { - status: "COMPLETED", - outputData: { result: `Hello ${task.inputData.name}` }, + status: "COMPLETED" as const, + outputData: { result: `Hello ${task.inputData?.name ?? "World"}` }, }; } @worker({ taskDefName: "process_payment", domain: "payments" }) async function processPayment(task: Task) { const result = await paymentGateway.charge(task.inputData.customerId, task.inputData.amount); - return { status: "COMPLETED", outputData: { transactionId: result.id } }; + return { status: "COMPLETED" as const, outputData: { transactionId: result.id } }; } -// Auto-discover and start all decorated workers const handler = new TaskHandler({ client, scanForDecorated: true }); await handler.startWorkers(); +``` + +### tsconfig setup -// Graceful shutdown +| Decorator style | tsconfig.json | +|-----------------|---------------| +| **New** (TypeScript 5.0+) | Omit `experimentalDecorators` — use class methods | +| **Legacy** | `"experimentalDecorators": true` — use standalone functions | + +**Graceful shutdown:** + +```typescript process.on("SIGTERM", async () => { await handler.stopWorkers(); process.exit(0); diff --git a/src/sdk/worker/decorators/__tests__/worker.test.ts b/src/sdk/worker/decorators/__tests__/worker.test.ts index 5845582a..1b95418f 100644 --- a/src/sdk/worker/decorators/__tests__/worker.test.ts +++ b/src/sdk/worker/decorators/__tests__/worker.test.ts @@ -220,6 +220,111 @@ describe("@worker decorator", () => { }); }); +describe("@worker decorator - New API (TypeScript 5.0+ Stage 3 decorators)", () => { + beforeEach(() => { + clearWorkerRegistry(); + }); + + afterEach(() => { + clearWorkerRegistry(); + }); + + test("should register when called with new decorator signature (value, context)", () => { + async function greetMethod(task: Task) { + return { + status: "COMPLETED" as const, + outputData: { result: `Hello ${(task.inputData as Record)?.name ?? "World"}` }, + }; + } + + // Simulate new decorator API: decorator(value, context) where context has kind + const decorator = worker({ taskDefName: "new_api_greet" }); + decorator(greetMethod, { kind: "method", name: "greet" }); + + const workers = getRegisteredWorkers(); + expect(workers).toHaveLength(1); + expect(workers[0].taskDefName).toBe("new_api_greet"); + expect(workers[0].executeFunction).toBe(greetMethod); + }); + + test("should return replacement function for new API (replaces class method)", () => { + async function originalMethod(task: Task) { + return { + status: "COMPLETED" as const, + outputData: { value: (task.inputData as Record).x + 1 }, + }; + } + + const decorator = worker({ taskDefName: "new_api_replace" }); + const replacement = decorator(originalMethod, { kind: "method", name: "compute" }); + + expect(typeof replacement).toBe("function"); + expect(replacement).not.toBe(originalMethod); + + // Replacement should execute the original when called normally + const result = (replacement as (task: Task) => Promise<{ status: string; outputData: unknown }>)( + { inputData: { x: 10 } } as Task + ); + return expect(result).resolves.toEqual({ + status: "COMPLETED", + outputData: { value: 11 }, + }); + }); + + test("should support dual-mode (workflow builder) when using new API", () => { + async function processTask(_task: Task) { + return { status: "COMPLETED" as const, outputData: { done: true } }; + } + + const decorator = worker({ taskDefName: "new_api_dual" }); + const replacement = decorator(processTask, { kind: "method", name: "process" }) as ( + arg: { taskRefName: string; inputParameters?: Record } + ) => unknown; + + const taskDef = replacement({ + taskRefName: "step_1", + inputParameters: { key: "value" }, + }); + + expect(taskDef).toMatchObject({ + name: "new_api_dual", + taskReferenceName: "step_1", + inputParameters: { key: "value" }, + }); + }); + + test("should register with options when using new API", () => { + async function workerFn(_task: Task) { + return { status: "COMPLETED" as const, outputData: {} }; + } + + const decorator = worker({ + taskDefName: "new_api_options", + concurrency: 5, + pollInterval: 300, + domain: "staging", + }); + decorator(workerFn, { kind: "method", name: "workerFn" }); + + const registered = getRegisteredWorker("new_api_options", "staging"); + expect(registered).toBeDefined(); + expect(registered?.concurrency).toBe(5); + expect(registered?.pollInterval).toBe(300); + expect(registered?.domain).toBe("staging"); + }); + + test("should throw if taskDefName missing with new API", () => { + async function fn(_task: Task) { + return { status: "COMPLETED" as const, outputData: {} }; + } + + const decorator = worker({} as { taskDefName: string }); + expect(() => { + decorator(fn, { kind: "method", name: "fn" }); + }).toThrow("requires 'taskDefName'"); + }); +}); + describe("Worker Registry", () => { beforeEach(() => { clearWorkerRegistry(); diff --git a/src/sdk/worker/decorators/worker.ts b/src/sdk/worker/decorators/worker.ts index 8973c2f4..c42ab292 100644 --- a/src/sdk/worker/decorators/worker.ts +++ b/src/sdk/worker/decorators/worker.ts @@ -208,20 +208,52 @@ export interface WorkerOptions { * } * ``` */ +/** + * Type guard for Stage 3 (TypeScript 5.0+) decorator context. + * New decorators pass (value, context) where context has a `kind` property. + */ +function isNewDecoratorContext( + arg: unknown +): arg is { kind: string; name: string | symbol } { + return ( + typeof arg === "object" && + arg !== null && + "kind" in arg && + typeof (arg as { kind: string }).kind === "string" + ); +} + export function worker(options: WorkerOptions) { - return function ( - target: unknown, - propertyKey?: string, + return function Promise>>( + target: T, + propertyKeyOrContext?: + | string + | { kind: string; name: string | symbol }, descriptor?: PropertyDescriptor - ) { - // Extract the function to register - const executeFunction = descriptor?.value || target; + ): T | PropertyDescriptor | void { + // Detect decorator API: new (Stage 3) vs legacy (experimentalDecorators) + let executeFunction: (task: Task) => Promise>; + let isNewApi = false; + + if (isNewDecoratorContext(propertyKeyOrContext)) { + // New decorator API: target is the method itself + executeFunction = target as (task: Task) => Promise< + Omit + >; + isNewApi = true; + } else { + // Legacy API: descriptor?.value (method) or target (standalone function) + const fn = (descriptor?.value ?? target) as ( + task: Task + ) => Promise>; + executeFunction = fn; + } // Validate that we have a function if (typeof executeFunction !== "function") { throw new Error( `@worker decorator can only be applied to functions. ` + - `Received: ${typeof executeFunction}` + `Received: ${typeof executeFunction}` ); } @@ -229,7 +261,7 @@ export function worker(options: WorkerOptions) { if (!options.taskDefName) { throw new Error( `@worker decorator requires 'taskDefName' option. ` + - `Example: @worker({ taskDefName: "my_task" })` + `Example: @worker({ taskDefName: "my_task" })` ); } @@ -238,16 +270,20 @@ export function worker(options: WorkerOptions) { let resolvedOutputSchema = options.outputSchema; if (options.inputType) { - resolvedInputSchema = generateSchemaFromClass(options.inputType) as unknown as Record; + resolvedInputSchema = generateSchemaFromClass( + options.inputType + ) as unknown as Record; } if (options.outputType) { - resolvedOutputSchema = generateSchemaFromClass(options.outputType) as unknown as Record; + resolvedOutputSchema = generateSchemaFromClass( + options.outputType + ) as unknown as Record; } // Create registered worker metadata const registeredWorker: RegisteredWorker = { taskDefName: options.taskDefName, - executeFunction: executeFunction as (task: Task) => Promise>, + executeFunction, concurrency: options.concurrency, pollInterval: options.pollInterval, domain: options.domain, @@ -285,7 +321,10 @@ export function worker(options: WorkerOptions) { ); } // Normal execution mode - return (executeFunction as (...args: unknown[]) => unknown).apply(this, args); + return (executeFunction as (...args: unknown[]) => unknown).apply( + this, + args + ); }; // Preserve original function name @@ -294,6 +333,12 @@ export function worker(options: WorkerOptions) { configurable: true, }); + if (isNewApi) { + // New decorator API: return replacement function (cast to T for type compatibility) + return dualModeFunction as unknown as T; + } + + // Legacy API if (descriptor) { descriptor.value = dualModeFunction; return descriptor; From 2692ce112db808b2b3bb45cb6ef7626f3d93d365 Mon Sep 17 00:00:00 2001 From: Andrew Lai Date: Thu, 12 Mar 2026 14:04:04 -0700 Subject: [PATCH 2/3] Update schema decorators and fix lint issue --- src/sdk/worker/decorators/worker.ts | 4 +- .../schema/__tests__/decorators.test.ts | 85 +++++++++++++++++ src/sdk/worker/schema/decorators.ts | 94 +++++++++++++++---- 3 files changed, 165 insertions(+), 18 deletions(-) diff --git a/src/sdk/worker/decorators/worker.ts b/src/sdk/worker/decorators/worker.ts index c42ab292..bb81ffd5 100644 --- a/src/sdk/worker/decorators/worker.ts +++ b/src/sdk/worker/decorators/worker.ts @@ -230,7 +230,7 @@ export function worker(options: WorkerOptions) { | string | { kind: string; name: string | symbol }, descriptor?: PropertyDescriptor - ): T | PropertyDescriptor | void { + ): T | PropertyDescriptor | undefined { // Detect decorator API: new (Stage 3) vs legacy (experimentalDecorators) let executeFunction: (task: Task) => Promise>; let isNewApi = false; @@ -343,6 +343,6 @@ export function worker(options: WorkerOptions) { descriptor.value = dualModeFunction; return descriptor; } - return dualModeFunction; + return dualModeFunction as unknown as T; }; } diff --git a/src/sdk/worker/schema/__tests__/decorators.test.ts b/src/sdk/worker/schema/__tests__/decorators.test.ts index a21fd4d0..931048bb 100644 --- a/src/sdk/worker/schema/__tests__/decorators.test.ts +++ b/src/sdk/worker/schema/__tests__/decorators.test.ts @@ -174,4 +174,89 @@ describe("@schemaField() decorator", () => { expect(address.required).toEqual(["street"]); }); }); + + describe("Stage 3 (TypeScript 5.0+) decorator API", () => { + it("should register fields when called with new decorator signature (value, context)", () => { + class TestClass { + name = ""; + } + // Simulate new decorator API: decorator(value, context) returns initializer + const decorator = schemaField({ type: "string" }); + const initializer = decorator(undefined, { + kind: "field", + name: "name", + }) as (initialValue: unknown) => unknown; + expect(typeof initializer).toBe("function"); + + // Initializer runs when instance is created; bind instance as `this` + const instance = new TestClass(); + initializer.call(instance, ""); + + const schema = generateSchemaFromClass(TestClass); + expect(schema.properties).toEqual({ name: { type: "string" } }); + }); + + it("should support required fields with new API", () => { + class TestClass { + id = ""; + count = 0; + } + const initId = schemaField({ type: "string", required: true })( + undefined, + { kind: "field", name: "id" } + ) as (v: unknown) => unknown; + const initCount = schemaField({ type: "number" })( + undefined, + { kind: "field", name: "count" } + ) as (v: unknown) => unknown; + + const instance = new TestClass(); + initId.call(instance, ""); + initCount.call(instance, 0); + + const schema = generateSchemaFromClass(TestClass); + expect(schema.properties).toEqual({ + id: { type: "string" }, + count: { type: "number" }, + }); + expect(schema.required).toEqual(["id"]); + }); + + it("should not duplicate metadata when initializer runs multiple times", () => { + class TestClass { + value = ""; + } + const initializer = schemaField({ type: "string" })( + undefined, + { kind: "field", name: "value" } + ) as (v: unknown) => unknown; + + const i1 = new TestClass(); + const i2 = new TestClass(); + const i3 = new TestClass(); + initializer.call(i1, ""); + initializer.call(i2, ""); + initializer.call(i3, ""); + + const schema = generateSchemaFromClass(TestClass); + expect(schema.properties).toEqual({ value: { type: "string" } }); + expect(Object.keys(schema.properties)).toHaveLength(1); + }); + + it("should work with explicit type when design:type unavailable (new API)", () => { + class TestClass { + count = 0; + } + const initializer = schemaField({ + type: "integer", + description: "Count", + })(undefined, { kind: "field", name: "count" }) as (v: unknown) => unknown; + initializer.call(new TestClass(), 0); + + const schema = generateSchemaFromClass(TestClass); + expect(schema.properties).toEqual({ + count: { type: "integer", description: "Count" }, + }); + }); + }); }); diff --git a/src/sdk/worker/schema/decorators.ts b/src/sdk/worker/schema/decorators.ts index 35edb7b0..387b511e 100644 --- a/src/sdk/worker/schema/decorators.ts +++ b/src/sdk/worker/schema/decorators.ts @@ -38,15 +38,57 @@ interface StoredFieldMeta extends SchemaFieldOptions { designType?: unknown; } +/** + * Type guard for Stage 3 (TypeScript 5.0+) decorator context. + * New decorators pass (value, context) where context has a `kind` property. + */ +function isNewDecoratorContext( + arg: unknown +): arg is { kind: string; name: string | symbol } { + return ( + typeof arg === "object" && + arg !== null && + "kind" in arg && + typeof (arg as { kind: string }).kind === "string" + ); +} + +/** + * Track (class, propertyKey) pairs already stored to avoid duplicates when + * the initializer runs on each instance (Stage 3 decorator API). + */ +const schemaFieldProcessed = new WeakMap>(); + +function storeSchemaFieldMetadata( + cls: object, + propertyKey: string, + options: SchemaFieldOptions, + designType?: unknown +): void { + const existing: StoredFieldMeta[] = + (Reflect.getOwnMetadata(SCHEMA_METADATA_KEY, cls) as StoredFieldMeta[] | undefined) ?? []; + + existing.push({ + ...options, + propertyKey, + designType, + }); + + Reflect.defineMetadata(SCHEMA_METADATA_KEY, existing, cls); +} + /** * Property decorator to define JSON Schema metadata on a class. * * When used with `generateSchemaFromClass()`, produces a JSON Schema draft-07 * object from the decorated properties. * - * If `emitDecoratorMetadata` is enabled in tsconfig.json, the TypeScript type - * is automatically inferred for `string`, `number`, `boolean` — no need to - * specify `type` explicitly for those. + * Supports both TypeScript 5.0+ (Stage 3) and legacy (experimentalDecorators) + * decorator APIs. + * + * If `emitDecoratorMetadata` is enabled in tsconfig.json (legacy mode), the + * TypeScript type is automatically inferred for `string`, `number`, `boolean` — + * no need to specify `type` explicitly for those. * * @example * ```typescript @@ -65,26 +107,46 @@ interface StoredFieldMeta extends SchemaFieldOptions { * ``` */ export function schemaField(options: SchemaFieldOptions = {}) { - return function (target: object, propertyKey: string) { - // Read existing field metadata for this class - const existing: StoredFieldMeta[] = - (Reflect.getOwnMetadata(SCHEMA_METADATA_KEY, target.constructor) as StoredFieldMeta[] | undefined) ?? []; + return function ( + targetOrValue: object | undefined, + propertyKeyOrContext?: string | { kind: string; name: string | symbol } + ): ((initialValue: unknown) => unknown) | undefined { + if (isNewDecoratorContext(propertyKeyOrContext)) { + // Stage 3 (TypeScript 5.0+) API: (value, context) + // Return initializer that runs when instance is created; `this` = instance + const propertyKey = String(propertyKeyOrContext.name); + return function (this: unknown, initialValue: unknown) { + const cls = (this as object).constructor as object; + const processed = schemaFieldProcessed.get(cls) ?? new Set(); + if (!processed.has(propertyKey)) { + processed.add(propertyKey); + schemaFieldProcessed.set(cls, processed); + let designType: unknown; + try { + designType = Reflect.getMetadata( + "design:type", + this as object, + propertyKey + ); + } catch { + // reflect-metadata may not emit design:type for Stage 3 decorators + } + storeSchemaFieldMetadata(cls, propertyKey, options, designType); + } + return initialValue; + }; + } - // Try to infer type from TypeScript metadata + // Legacy (experimentalDecorators) API: (target, propertyKey) + const target = targetOrValue as object; + const propertyKey = propertyKeyOrContext as string; let designType: unknown; try { designType = Reflect.getMetadata("design:type", target, propertyKey); } catch { // reflect-metadata not available — user must provide type explicitly } - - existing.push({ - ...options, - propertyKey, - designType, - }); - - Reflect.defineMetadata(SCHEMA_METADATA_KEY, existing, target.constructor); + storeSchemaFieldMetadata(target.constructor as object, propertyKey, options, designType); }; } From 105682480ee7dcc7bfda7b91aa9637e4c0f16ead Mon Sep 17 00:00:00 2001 From: Andrew Lai Date: Thu, 12 Mar 2026 14:54:39 -0700 Subject: [PATCH 3/3] Fix types --- package-lock.json | 4 ++-- src/sdk/worker/decorators/worker.ts | 37 +++++++++++++++++++++-------- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index b1598f58..4fc3a619 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@io-orkes/conductor-javascript", - "version": "v3.0.0", + "version": "v0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@io-orkes/conductor-javascript", - "version": "v3.0.0", + "version": "v0.0.0", "license": "Apache-2.0", "dependencies": { "reflect-metadata": "^0.2.2" diff --git a/src/sdk/worker/decorators/worker.ts b/src/sdk/worker/decorators/worker.ts index bb81ffd5..d3c3a903 100644 --- a/src/sdk/worker/decorators/worker.ts +++ b/src/sdk/worker/decorators/worker.ts @@ -208,13 +208,17 @@ export interface WorkerOptions { * } * ``` */ +/** Minimal context shape for Stage 3 method decorators (TypeScript 5.0+). */ +interface MethodDecoratorContext { + kind: string; + name: string | symbol; +} + /** * Type guard for Stage 3 (TypeScript 5.0+) decorator context. * New decorators pass (value, context) where context has a `kind` property. */ -function isNewDecoratorContext( - arg: unknown -): arg is { kind: string; name: string | symbol } { +function isNewDecoratorContext(arg: unknown): arg is MethodDecoratorContext { return ( typeof arg === "object" && arg !== null && @@ -223,12 +227,23 @@ function isNewDecoratorContext( ); } +type WorkerMethod = ( + task: Task +) => Promise>; + export function worker(options: WorkerOptions) { - return function Promise>>( - target: T, - propertyKeyOrContext?: - | string - | { kind: string; name: string | symbol }, + function decorator( + value: T, + context: MethodDecoratorContext + ): T | undefined; + function decorator( + target: object, + propertyKey?: string, + descriptor?: PropertyDescriptor + ): PropertyDescriptor | WorkerMethod | undefined; + function decorator( + target: T | object, + propertyKeyOrContext?: string | MethodDecoratorContext, descriptor?: PropertyDescriptor ): T | PropertyDescriptor | undefined { // Detect decorator API: new (Stage 3) vs legacy (experimentalDecorators) @@ -334,7 +349,7 @@ export function worker(options: WorkerOptions) { }); if (isNewApi) { - // New decorator API: return replacement function (cast to T for type compatibility) + // New decorator API: return replacement function return dualModeFunction as unknown as T; } @@ -344,5 +359,7 @@ export function worker(options: WorkerOptions) { return descriptor; } return dualModeFunction as unknown as T; - }; + } + + return decorator; }