From 9ba3b278988a0e9a4bd2e127a2cbaee5cf4edfff Mon Sep 17 00:00:00 2001 From: Copilot Date: Wed, 15 Apr 2026 10:32:28 +0000 Subject: [PATCH] fix: normalize entity query prefix in client.getEntities() The getEntities() method was passing the instanceIdStartsWith query parameter directly to the gRPC backend without normalizing it. Entity names are stored lowercase with '@' prefix format (@name@key), so queries with mixed-case names or missing '@' prefix would silently return zero results. The normalizeInstanceIdPrefix() utility already existed for this purpose but was not called by the client. This change applies the normalization before sending the query to the backend. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/durabletask-js/src/client/client.ts | 7 +- .../durabletask-js/test/entity-client.spec.ts | 93 +++++++++++++++++++ 2 files changed, 97 insertions(+), 3 deletions(-) diff --git a/packages/durabletask-js/src/client/client.ts b/packages/durabletask-js/src/client/client.ts index d0be02e..1bdda34 100644 --- a/packages/durabletask-js/src/client/client.ts +++ b/packages/durabletask-js/src/client/client.ts @@ -31,7 +31,7 @@ import { mapToRecord } from "../utils/tags.util"; import { populateTagsMap } from "../utils/pb-helper.util"; import { EntityInstanceId } from "../entities/entity-instance-id"; import { EntityMetadata, createEntityMetadata, createEntityMetadataWithoutState } from "../entities/entity-metadata"; -import { EntityQuery } from "../entities/entity-query"; +import { EntityQuery, normalizeInstanceIdPrefix } from "../entities/entity-query"; import { SignalEntityOptions } from "../entities/signal-entity-options"; import { CleanEntityStorageRequest, @@ -1103,9 +1103,10 @@ export class TaskHubGrpcClient { const req = new pb.QueryEntitiesRequest(); const protoQuery = new pb.EntityQuery(); - if (query?.instanceIdStartsWith) { + const normalizedPrefix = normalizeInstanceIdPrefix(query?.instanceIdStartsWith); + if (normalizedPrefix) { const prefix = new StringValue(); - prefix.setValue(query.instanceIdStartsWith); + prefix.setValue(normalizedPrefix); protoQuery.setInstanceidstartswith(prefix); } diff --git a/packages/durabletask-js/test/entity-client.spec.ts b/packages/durabletask-js/test/entity-client.spec.ts index e77b46a..712294a 100644 --- a/packages/durabletask-js/test/entity-client.spec.ts +++ b/packages/durabletask-js/test/entity-client.spec.ts @@ -351,3 +351,96 @@ describe("EntityInstanceId.fromString", () => { expect(() => EntityInstanceId.fromString("@onlyname")).toThrow(); }); }); + +describe("getEntities query normalization", () => { + const { TaskHubGrpcClient } = require("../src"); + + function getStub(client: InstanceType): any { + return (client as any)._stub; + } + + function mockQueryEntities( + client: InstanceType, + captureRequest: (req: pb.QueryEntitiesRequest) => void, + ): void { + getStub(client).queryEntities = (req: pb.QueryEntitiesRequest, _metadata: any, callback: any) => { + captureRequest(req); + const res = new pb.QueryEntitiesResponse(); + callback(null, res); + return {}; + }; + } + + it("should normalize mixed-case instanceIdStartsWith prefix", async () => { + const client = new TaskHubGrpcClient({ hostAddress: "localhost:4001" }); + let capturedReq: pb.QueryEntitiesRequest | undefined; + + mockQueryEntities(client, (req) => { capturedReq = req; }); + + const query: EntityQuery = { instanceIdStartsWith: "Counter" }; + const pageable = client.getEntities(query); + + // Consume the first page to trigger the gRPC call + for await (const _entity of pageable) { + // no-op + } + + expect(capturedReq).toBeDefined(); + const protoPrefix = capturedReq!.getQuery()?.getInstanceidstartswith()?.getValue(); + expect(protoPrefix).toBe("@counter"); + }); + + it("should normalize prefix with name and key separator", async () => { + const client = new TaskHubGrpcClient({ hostAddress: "localhost:4001" }); + let capturedReq: pb.QueryEntitiesRequest | undefined; + + mockQueryEntities(client, (req) => { capturedReq = req; }); + + const query: EntityQuery = { instanceIdStartsWith: "Counter@User-123" }; + const pageable = client.getEntities(query); + + for await (const _entity of pageable) { + // no-op + } + + expect(capturedReq).toBeDefined(); + const protoPrefix = capturedReq!.getQuery()?.getInstanceidstartswith()?.getValue(); + expect(protoPrefix).toBe("@counter@User-123"); + }); + + it("should not set prefix when instanceIdStartsWith is undefined", async () => { + const client = new TaskHubGrpcClient({ hostAddress: "localhost:4001" }); + let capturedReq: pb.QueryEntitiesRequest | undefined; + + mockQueryEntities(client, (req) => { capturedReq = req; }); + + const query: EntityQuery = {}; + const pageable = client.getEntities(query); + + for await (const _entity of pageable) { + // no-op + } + + expect(capturedReq).toBeDefined(); + const protoPrefix = capturedReq!.getQuery()?.getInstanceidstartswith(); + expect(protoPrefix).toBeUndefined(); + }); + + it("should preserve already-normalized prefix", async () => { + const client = new TaskHubGrpcClient({ hostAddress: "localhost:4001" }); + let capturedReq: pb.QueryEntitiesRequest | undefined; + + mockQueryEntities(client, (req) => { capturedReq = req; }); + + const query: EntityQuery = { instanceIdStartsWith: "@counter@" }; + const pageable = client.getEntities(query); + + for await (const _entity of pageable) { + // no-op + } + + expect(capturedReq).toBeDefined(); + const protoPrefix = capturedReq!.getQuery()?.getInstanceidstartswith()?.getValue(); + expect(protoPrefix).toBe("@counter@"); + }); +});