Skip to content

Commit 8f77082

Browse files
btraversclaude
andcommitted
feat(client)!: split TypedClient/ContractClient and overhaul the client surface
Wave 3 of the v8 review remediation (items 11-18): - 11: TypedClient is now connection-scoped (no type parameter): static create({ client, interceptors }) holds the schedule-presence check + eager ensureConnected; for(contract) hands out memoized, identity-stable ContractClient<TContract> bindings (the renamed old class, constructor @internal). createOrThrow and the create options' contract field are removed; ContractClient is exported. - 12: handle.result() output-validation errors now carry the real workflow NAME (the workflowId was passed as workflowName); WorkflowValidationError gains a workflowId field, populated on start/execute/signalWithStart/result paths. - 13: schedule parity — tagged ScheduleAlreadyExistsError / ScheduleNotFoundError classified from Temporal's ScheduleAlreadyRunning / ScheduleNotFoundError; create returns Err(ScheduleAlreadyExistsError); handle methods return AsyncResult<void, ScheduleNotFoundError>; new update + backfill on the typed handle and list on the typed schedule client; TypedScheduleClient constructor is @internal. - 14: readonly raw escape hatch on the TypedClient root; typed workflow handles carry runId + firstExecutionRunId (signaledRunId stays on signalWithStart's); getHandle is now SYNCHRONOUS returning Result<handle, WorkflowNotInContractError> and accepts runId + GetWorkflowHandleOptions (firstExecutionRunId interlock); typed startUpdate beside executeUpdate with a parsed-on-receive result(). - 15: WorkflowNotFoundError renamed WorkflowNotInContractError (it squatted on the SDK's execution-not-found name); WorkflowExecutionNotFoundError unchanged. - 16: deleted the six unused ClientInfer* type exports; fixed the inverted direction comment in types.ts; {} fallbacks are now Record<never, never>. - 17: executeWorkflow composes classifyStartError + the new shared classifyExecutionResultError (rehydrate-then-classify) helper, also used by handle.result(). - 18: TSDoc/doc polish (orphaned TypedSearchAttributeMap doc, "Thrown when" -> "Surfaced on the Err channel when", module docs above imports, readonly handle/error fields, typeof-per-kind runtime checks for search-attribute VALUES, fixed interceptors.ts combinator names, README rewritten to the TypedClient.create({ client }).for(contract) two-step with .get()); payload arguments are omittable when the schema accepts undefined (input-less signals/queries/updates send empty args); expanded type-level and unit/integration suites incl. second-contract fixtures proving one root drives two task queues. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 2d2f33b commit 8f77082

14 files changed

Lines changed: 2157 additions & 527 deletions

packages/client/README.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,21 @@ pnpm add @temporal-contract/client @temporal-contract/contract @temporalio/clien
1616
import { TypedClient } from "@temporal-contract/client";
1717
import { Connection, Client } from "@temporalio/client";
1818

19+
import { myContract } from "./contract.js";
20+
1921
const connection = await Connection.connect({ address: "localhost:7233" });
2022
const temporalClient = new Client({ connection });
21-
const client = await TypedClient.create({
22-
contract: myContract,
23-
client: temporalClient,
24-
}).getOrThrow();
23+
24+
// One connection-scoped root per process. `create` returns
25+
// `AsyncResult<TypedClient, never>` — setup faults are defects, so `.get()`
26+
// unwraps directly.
27+
const client = await TypedClient.create({ client: temporalClient }).get();
28+
29+
// Bind a contract — synchronous, infallible, memoized.
30+
const orders = client.for(myContract);
2531

2632
// Execute workflow (fully typed!)
27-
const result = await client.executeWorkflow("processOrder", {
33+
const result = await orders.executeWorkflow("processOrder", {
2834
workflowId: "order-123",
2935
args: { orderId: "ORD-123" },
3036
});

packages/client/src/__tests__/client.spec.ts

Lines changed: 132 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,23 @@ import { Worker } from "@temporalio/worker";
77
import { P } from "unthrown";
88
import { describe, expect, vi, beforeEach } from "vitest";
99

10-
import { TypedClient } from "../client.js";
10+
import { type ContractClient, TypedClient } from "../client.js";
1111
import { WorkflowValidationError } from "../errors.js";
12+
import { secondContract } from "./second.contract.js";
1213
import { testContract } from "./test.contract.js";
1314

1415
// ============================================================================
1516
// Test Setup
1617
// ============================================================================
1718

18-
const it = baseIt.extend<{
19+
type WorkerFixtures = {
1920
worker: Worker;
20-
client: TypedClient<typeof testContract>;
21-
}>({
21+
secondWorker: Worker;
22+
root: TypedClient;
23+
client: ContractClient<typeof testContract>;
24+
};
25+
26+
const it = baseIt.extend<WorkerFixtures>({
2227
worker: [
2328
async ({ workerConnection }, use) => {
2429
// Create and start worker
@@ -57,19 +62,44 @@ const it = baseIt.extend<{
5762
},
5863
{ auto: true },
5964
],
60-
client: async ({ clientConnection }, use) => {
61-
// Create typed client
65+
// Second worker on the second contract's task queue — proves one
66+
// connection-scoped root drives multiple contracts (see the
67+
// "multiple contracts, one root" suite).
68+
secondWorker: [
69+
async ({ workerConnection }, use) => {
70+
const worker = await Worker.create({
71+
connection: workerConnection,
72+
namespace: "default",
73+
taskQueue: secondContract.taskQueue,
74+
workflowsPath: workflowPath("second.workflows"),
75+
});
76+
77+
worker.run().catch((err) => {
78+
console.error("Second worker failed:", err);
79+
});
80+
81+
await vi.waitFor(() => worker.getState() === "RUNNING", { interval: 100 });
82+
83+
await use(worker);
84+
85+
await worker.shutdown();
86+
87+
await vi.waitFor(() => worker.getState() === "STOPPED", { interval: 100 });
88+
},
89+
{ auto: true },
90+
],
91+
root: async ({ clientConnection }, use) => {
92+
// One connection-scoped root per process; contracts bind via `for()`.
6293
const rawClient = new Client({
6394
connection: clientConnection,
6495
namespace: "default",
6596
});
66-
const clientResult = await TypedClient.create({ contract: testContract, client: rawClient });
67-
if (!clientResult.isOk()) {
68-
throw clientResult.isErr() ? clientResult.error : clientResult.cause;
69-
}
70-
const client = clientResult.value;
97+
const root = (await TypedClient.create({ client: rawClient })).get();
7198

72-
await use(client);
99+
await use(root);
100+
},
101+
client: async ({ root }, use) => {
102+
await use(root.for(testContract));
73103
},
74104
});
75105

@@ -142,8 +172,9 @@ describe("Client Package - Integration Tests", () => {
142172
args: input,
143173
});
144174

145-
// WHEN
146-
const handleResult = await client.getHandle("simpleWorkflow", workflowId);
175+
// WHEN — getHandle is synchronous: the only failure mode is a
176+
// workflow name missing from the contract.
177+
const handleResult = client.getHandle("simpleWorkflow", workflowId);
147178

148179
// THEN
149180
expect(handleResult).toBeOk();
@@ -158,6 +189,56 @@ describe("Client Package - Integration Tests", () => {
158189
});
159190
});
160191

192+
describe("Multiple contracts, one root", () => {
193+
it("routes each contract's workflows to its own task queue through one root", async ({
194+
root,
195+
client,
196+
}) => {
197+
// GIVEN — the shared root, testContract already bound as `client`.
198+
const second = root.for(secondContract);
199+
200+
// WHEN — drive both contracts through the same connection.
201+
const first = await client.executeWorkflow("simpleWorkflow", {
202+
workflowId: `multi-first-${Date.now()}`,
203+
args: { value: "from-first" },
204+
});
205+
const echoed = await second.executeWorkflow("echoWorkflow", {
206+
workflowId: `multi-second-${Date.now()}`,
207+
args: { text: "from-second" },
208+
});
209+
210+
// THEN — each contract's worker (polling its own queue) answered.
211+
expect(first).toBeOk();
212+
if (first.isOk()) {
213+
expect(first.value).toEqual({ result: "Processed: from-first" });
214+
}
215+
expect(echoed).toBeOk();
216+
if (echoed.isOk()) {
217+
expect(echoed.value).toEqual({ echoed: "second-queue: from-second" });
218+
}
219+
220+
// Binding is memoized — the same instance serves repeated `for()`.
221+
expect(root.for(secondContract)).toBe(second);
222+
});
223+
});
224+
225+
describe("Handle identifiers", () => {
226+
it("startWorkflow handles carry firstExecutionRunId and runId", async ({ client }) => {
227+
const handleResult = await client.startWorkflow("simpleWorkflow", {
228+
workflowId: `run-ids-${Date.now()}`,
229+
args: { value: "ids" },
230+
});
231+
232+
expect(handleResult).toBeOk();
233+
if (!handleResult.isOk()) throw new Error("Expected Ok result");
234+
const handle = handleResult.value;
235+
expect(typeof handle.firstExecutionRunId).toBe("string");
236+
expect(handle.runId).toBe(handle.firstExecutionRunId);
237+
238+
await handle.result();
239+
});
240+
});
241+
161242
describe("Wire format (D1) — transforms apply exactly once per boundary", () => {
162243
it("sends the original input, receiver parses once; output parsed once by the client", async ({
163244
client,
@@ -317,6 +398,42 @@ describe("Client Package - Integration Tests", () => {
317398
expect(result.value).toEqual({ finalValue: 15 });
318399
}
319400
});
401+
402+
it("should start an update via startUpdate and await its result on the update handle", async ({
403+
client,
404+
}) => {
405+
// GIVEN
406+
const workflowId = `start-update-test-${Date.now()}`;
407+
const handleResult = await client.startWorkflow("interactiveWorkflow", {
408+
workflowId,
409+
args: { initialValue: 4 },
410+
});
411+
412+
expect(handleResult).toBeOk();
413+
if (!handleResult.isOk()) throw new Error("Expected Ok result");
414+
const handle = handleResult.value;
415+
416+
// WHEN — start the update without waiting for completion, then await
417+
// its result through the typed update handle.
418+
const updateHandleResult = await handle.startUpdate("multiply", {
419+
args: { factor: 5 },
420+
});
421+
422+
// THEN
423+
expect(updateHandleResult).toBeOk();
424+
if (!updateHandleResult.isOk()) throw new Error("Expected Ok result");
425+
const updateHandle = updateHandleResult.value;
426+
expect(updateHandle.workflowId).toBe(workflowId);
427+
expect(typeof updateHandle.updateId).toBe("string");
428+
429+
const updateResult = await updateHandle.result();
430+
expect(updateResult).toBeOk();
431+
if (updateResult.isOk()) {
432+
expect(updateResult.value).toEqual({ newValue: 20 }); // 4 * 5
433+
}
434+
435+
await handle.result();
436+
});
320437
});
321438

322439
describe("Workflow Handle Operations", () => {
@@ -431,7 +548,7 @@ describe("Client Package - Integration Tests", () => {
431548
},
432549
errCases: (matcher) =>
433550
matcher.with(
434-
P.tag("@temporal-contract/WorkflowNotFoundError"),
551+
P.tag("@temporal-contract/WorkflowNotInContractError"),
435552
P.tag("@temporal-contract/WorkflowValidationError"),
436553
P.tag("@temporal-contract/WorkflowAlreadyStartedError"),
437554
P.tag("@temporal-contract/WorkflowFailedError"),
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { defineContract, defineWorkflow } from "@temporal-contract/contract";
2+
import { z } from "zod";
3+
4+
/**
5+
* Second test contract on its own task queue — exercised by the
6+
* "multiple contracts, one root" integration case: one `TypedClient`
7+
* root drives both this contract and `testContract`, each routed to
8+
* its own queue via `root.for(contract)`.
9+
*/
10+
export const secondContract = defineContract({
11+
taskQueue: "second-client-queue",
12+
workflows: {
13+
echoWorkflow: defineWorkflow({
14+
input: z.object({
15+
text: z.string(),
16+
}),
17+
output: z.object({
18+
echoed: z.string(),
19+
}),
20+
}),
21+
},
22+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* Workflow implementations for `second.contract.ts` — registered on the
3+
* second worker (own task queue) in the integration suite.
4+
*/
5+
export async function echoWorkflow(args: { text: string }): Promise<{ echoed: string }> {
6+
return {
7+
echoed: `second-queue: ${args.text}`,
8+
};
9+
}

0 commit comments

Comments
 (0)