diff --git a/packages/azure-functions-durable/README.md b/packages/azure-functions-durable/README.md index 49adb637..d45ea14d 100644 --- a/packages/azure-functions-durable/README.md +++ b/packages/azure-functions-durable/README.md @@ -52,6 +52,14 @@ changed: (testing utilities), `ManagedIdentityTokenSource`, and the entity-lock types above. `TaskFailedError` is re-exported from the core SDK (aggregate failures surface as JS-native `AggregateError`); use the core `TestOrchestrationWorker` / `TestOrchestrationClient` for orchestration unit tests. +- **A plain non-generator classic orchestrator is no longer supported.** A classic v3 orchestrator + written as a *synchronous, single-argument, non-generator* function `(context) => context.df.*` + (one that never `yield`s) is now treated as a **core-native** orchestrator and receives the core + `OrchestrationContext`, which has no `.df`. This resolves + [#321](https://github.com/microsoft/durabletask-js/issues/321), where a core-native + `(ctx) => ctx.instanceId` was mis-routed to the classic context. Standard classic orchestrators — + sync **generators** (`function*`) using `context.df.*` — are unaffected; convert any non-generator + classic orchestrator to generator form, or to the core-native `ctx.*` API. ## Getting started diff --git a/packages/azure-functions-durable/src/orchestration-context.ts b/packages/azure-functions-durable/src/orchestration-context.ts index 14339102..e839d41d 100644 --- a/packages/azure-functions-durable/src/orchestration-context.ts +++ b/packages/azure-functions-durable/src/orchestration-context.ts @@ -218,8 +218,12 @@ export type ClassicOrchestrator = ( * generator to `yield` durable tasks), so `async` unambiguously means core-native. * - `function*` (classic v3 sync generator) is wrapped; the engine cannot drive a sync generator, so * the wrapper delegates to it via `yield*`. - * - A plain SYNC (non-generator) function is ambiguous, so fall back to arity: a lone `context` - * parameter is treated as classic, `(ctx, input)` as core-native. + * - A plain SYNC (non-generator) function is CORE-NATIVE regardless of arity and passes through + * unchanged, receiving the core {@link OrchestrationContext}. This fixes #321 (a native + * `(ctx) => ctx.instanceId` previously mis-routed by arity to the classic context). BREAKING: a + * classic v3 orchestrator written as a plain non-generator using `context.df.*` is no longer + * supported — convert it to a `function*` generator (the standard classic shape, unaffected) or + * to the core-native `ctx.*` API. */ export function wrapOrchestrator(handler: TOrchestrator | ClassicOrchestrator): TOrchestrator { if (typeof handler === "function" && isClassicOrchestrator(handler)) { @@ -274,9 +278,11 @@ function isClassicOrchestrator(handler: TOrchestrator | ClassicOrchestrator): bo if (kind === "GeneratorFunction") { return true; // classic v3: a sync generator the engine can't drive on its own. } - // Plain SYNC (non-generator) function: kind is ambiguous, so fall back to arity. A lone - // `context` parameter is the classic v3 shape; `(ctx, input)` is core-native. - return handler.length <= 1; + // Plain SYNC (non-generator) function is CORE-NATIVE regardless of arity: it passes through + // unchanged and receives the core OrchestrationContext. Only a sync generator (`function*`) is + // classic (handled above). A classic v3 orchestrator written as a plain non-generator using + // `context.df.*` is no longer supported (breaking) — see #321 and the package README/CHANGELOG. + return false; } /** diff --git a/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts b/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts index e1d39137..add2a1bf 100644 --- a/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts +++ b/packages/azure-functions-durable/test/unit/orchestration-context.spec.ts @@ -272,9 +272,10 @@ describe("wrapOrchestrator", () => { }); it("exposes a replay-safe logger as context.log/error on the classic context", async () => { - // A classic orchestrator may be a plain (non-generator) function that returns a value; the - // wrapper still invokes it with the full classic context, so log wiring is exercised here. - const classic = (context: ClassicOrchestrationContext): string => { + // A classic orchestrator is a sync generator (`function*`); the wrapper invokes it with the full + // classic context, so the replay-safe log wiring is exercised here. + // eslint-disable-next-line require-yield + const classic = function* (context: ClassicOrchestrationContext): Generator, string, unknown> { context.log("hi"); context.error("boom"); return "logged"; @@ -292,6 +293,22 @@ describe("wrapOrchestrator", () => { expect(replaySafeLogger.info).toHaveBeenCalledWith("hi"); expect(replaySafeLogger.error).toHaveBeenCalledWith("boom"); }); + + it("returns a plain sync single-argument core-native orchestrator unchanged (#321)", () => { + // #321: a plain sync, single-argument, non-generator `(ctx) => value` is core-native. It must + // pass through wrapOrchestrator UNCHANGED (identity) and receive the core OrchestrationContext. + // Previously arity-based detection mis-routed it to the classic `{ df, log }` context, so + // `ctx.instanceId` was undefined and core members like `ctx.newGuid()` threw. + const native = (ctx: OrchestrationContext): string => `native:${ctx.instanceId}`; + + const wrapped = wrapOrchestrator(native as unknown as TOrchestrator); + expect(wrapped).toBe(native as unknown as TOrchestrator); // identity passthrough, not wrapped + + const { ctx } = createFakeCoreContext(); + const out = (wrapped as unknown as (c: OrchestrationContext) => string)(ctx); + expect(out.startsWith("native:")).toBe(true); + expect(out).not.toContain("undefined"); + }); }); describe("wrapOrchestrator end-to-end (real core executor)", () => {