Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion packages/durabletask-js/src/worker/entity-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import * as pb from "../proto/orchestrator_service_pb";
import { StringValue } from "google-protobuf/google/protobuf/wrappers_pb";
import { Timestamp } from "google-protobuf/google/protobuf/timestamp_pb";
import { randomUUID } from "crypto";
import { parseJsonField } from "../utils/pb-helper.util";

/**
* Internal type representing actions collected during entity execution.
Expand Down Expand Up @@ -356,7 +357,11 @@ export class TaskEntityShim {

try {
const inputValue = opRequest.getInput();
const input = inputValue ? JSON.parse(inputValue.getValue()) : undefined;
// Use the shared parseJsonField helper so an empty StringValue is treated as
// "no input" (returns undefined) instead of crashing on JSON.parse(""). A
// StringValue object is always truthy, so the previous `inputValue ? ...`
// guard did not protect against an empty wrapped string.
const input = parseJsonField(inputValue);

// Set operation details
this.operationShim.setNameAndInput(opRequest.getOperation(), input);
Expand Down
86 changes: 86 additions & 0 deletions packages/durabletask-js/test/entity-executor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,17 @@ class CounterEntity extends TaskEntity<{ count: number }> {
}
}

// Entity used to assert how operation input is delivered to the dispatched method.
class InputCaptureEntity extends TaskEntity<{ calls: number }> {
capture(input?: unknown): { hadInput: boolean; value: unknown } {
return { hadInput: input !== undefined, value: input ?? null };
}

protected initializeState(): { calls: number } {
return { calls: 0 };
}
}

describe("TaskEntityShim", () => {
const entityId = new EntityInstanceId("counter", "test");

Expand Down Expand Up @@ -614,4 +625,79 @@ describe("TaskEntityShim", () => {
expect(JSON.parse(result.getResultsList()[1].getSuccess()!.getResult()!.getValue())).toBe(10);
});
});

describe("empty operation input", () => {
it("should treat an empty StringValue input as no input instead of throwing SyntaxError", async () => {
const entity = new CounterEntity();
const shim = new TaskEntityShim(entity, entityId);

// Manually build a request whose operation input is an empty StringValue.
// A StringValue wrapping "" is a truthy object, so the previous inline
// `inputValue ? JSON.parse(inputValue.getValue()) : undefined` guard used to
// call JSON.parse("") and throw "Unexpected end of JSON input", failing the
// operation and rolling it back instead of treating it as no input.
const request = new pb.EntityBatchRequest();
request.setInstanceid(entityId.toString());

const stateValue = new StringValue();
stateValue.setValue(JSON.stringify({ count: 7 }));
request.setEntitystate(stateValue);

const opRequest = new pb.OperationRequest();
opRequest.setOperation("get");
opRequest.setInput(new StringValue()); // empty StringValue, getValue() === ""
request.addOperations(opRequest);

const result = await shim.executeAsync(request);

const opResult = result.getResultsList()[0];
expect(opResult.hasFailure()).toBe(false);
expect(opResult.hasSuccess()).toBe(true);
expect(JSON.parse(opResult.getSuccess()!.getResult()!.getValue())).toBe(7);
});

it("should dispatch the operation with no input when the StringValue is empty", async () => {
const entity = new InputCaptureEntity();
const shim = new TaskEntityShim(entity, entityId);

const request = new pb.EntityBatchRequest();
request.setInstanceid(entityId.toString());

const opRequest = new pb.OperationRequest();
opRequest.setOperation("capture");
opRequest.setInput(new StringValue()); // empty StringValue
request.addOperations(opRequest);

const result = await shim.executeAsync(request);

const opResult = result.getResultsList()[0];
expect(opResult.hasSuccess()).toBe(true);
const captured = JSON.parse(opResult.getSuccess()!.getResult()!.getValue());
expect(captured).toEqual({ hadInput: false, value: null });
});

it("should still deliver a genuine empty-string JSON value when explicitly provided", async () => {
// Sanity check: a StringValue holding the JSON text '""' is a real empty
// string input and must be delivered as "" (not dropped as "no input").
const entity = new InputCaptureEntity();
const shim = new TaskEntityShim(entity, entityId);

const request = new pb.EntityBatchRequest();
request.setInstanceid(entityId.toString());

const opRequest = new pb.OperationRequest();
opRequest.setOperation("capture");
const inputValue = new StringValue();
inputValue.setValue(JSON.stringify("")); // the two-character string: ""
opRequest.setInput(inputValue);
request.addOperations(opRequest);

const result = await shim.executeAsync(request);

const opResult = result.getResultsList()[0];
expect(opResult.hasSuccess()).toBe(true);
const captured = JSON.parse(opResult.getSuccess()!.getResult()!.getValue());
expect(captured).toEqual({ hadInput: true, value: "" });
});
});
});
Loading