Skip to content

Commit 841fd72

Browse files
Send SANO links in requests (#2143)
1 parent a9e2c2e commit 841fd72

5 files changed

Lines changed: 85 additions & 37 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,8 @@ to docs, or any other relevant information.
3030
### Breaking Changes
3131

3232
- `WorkflowHandle.runId` in `@temporalio/nexus` is now an optional property to support creating a handle using only a workflow ID.
33+
34+
### Changed
35+
36+
- Standalone Nexus operation links are now formatted to align with server side support.
37+
- Standalone Nexus operation links are now forwarded on start workflow and signal requests.

packages/client/src/workflow-client.ts

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1212,8 +1212,6 @@ export class WorkflowClient extends BaseClient {
12121212
const dataConverter = this.dataConverter;
12131213
const context = this.workflowSerializationContext(input.workflowExecution.workflowId!);
12141214
const internalOptions = (input as InternalWorkflowSignalInput)[InternalWorkflowSignalOptionsSymbol];
1215-
// Server currently only supports workflow_event and batch_job link types.
1216-
const links = internalOptions?.links?.filter((link) => link.workflowEvent != null || link.batchJob != null);
12171215
const req: temporal.api.workflowservice.v1.ISignalWorkflowExecutionRequest = {
12181216
identity: this.options.identity,
12191217
namespace: this.options.namespace,
@@ -1223,7 +1221,7 @@ export class WorkflowClient extends BaseClient {
12231221
signalName: input.signalName,
12241222
header: { fields: input.headers },
12251223
input: { payloads: await encodeToPayloadsWithContext(dataConverter, context, input.args) },
1226-
links,
1224+
links: internalOptions?.links,
12271225
};
12281226
try {
12291227
const response = await this.workflowService.signalWorkflowExecution(req);
@@ -1248,8 +1246,6 @@ export class WorkflowClient extends BaseClient {
12481246
const dataConverter = this.dataConverter;
12491247
const context = this.workflowSerializationContext(options.workflowId);
12501248
const internalOptions = (options as InternalWorkflowStartOptions)[InternalWorkflowStartOptionsSymbol];
1251-
// Server currently only supports workflow_event and batch_job link types.
1252-
const links = internalOptions?.links?.filter((link) => link.workflowEvent != null || link.batchJob != null);
12531249
const req: temporal.api.workflowservice.v1.ISignalWithStartWorkflowExecutionRequest = {
12541250
namespace: this.options.namespace,
12551251
identity,
@@ -1282,7 +1278,7 @@ export class WorkflowClient extends BaseClient {
12821278
userMetadata: await encodeUserMetadata(dataConverter, options.staticSummary, options.staticDetails, context),
12831279
priority: options.priority ? compilePriority(options.priority) : undefined,
12841280
versioningOverride: options.versioningOverride ?? undefined,
1285-
links,
1281+
links: internalOptions?.links,
12861282
};
12871283
try {
12881284
const response = await this.workflowService.signalWithStartWorkflowExecution(req);
@@ -1350,19 +1346,6 @@ export class WorkflowClient extends BaseClient {
13501346
);
13511347
}
13521348

1353-
// Server currently only supports workflow_event and batch_job
1354-
// link types. This filter should be removed or adapted as
1355-
// server-side support comes online.
1356-
// See https://github.com/temporalio/temporal/issues/10345
1357-
const links = internalOptions?.links?.filter((link) => link.workflowEvent != null || link.batchJob != null);
1358-
1359-
const completionCallbacks = internalOptions?.completionCallbacks?.map((cb) => {
1360-
const links = cb?.links?.filter((link) => link.workflowEvent != null || link.batchJob != null);
1361-
return { ...cb, links };
1362-
});
1363-
1364-
const resolvedInternalOptions = { ...(internalOptions ?? {}), links, completionCallbacks };
1365-
13661349
return {
13671350
namespace,
13681351
identity,
@@ -1394,7 +1377,7 @@ export class WorkflowClient extends BaseClient {
13941377
priority: opts.priority ? compilePriority(opts.priority) : undefined,
13951378
versioningOverride: opts.versioningOverride ?? undefined,
13961379
requestEagerExecution: opts.requestEagerStart,
1397-
...filterNullAndUndefined(resolvedInternalOptions),
1380+
...filterNullAndUndefined(internalOptions ?? {}),
13981381
};
13991382
}
14001383

packages/nexus/src/__tests__/test-nexus-link-converter.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ test('convertNexusOperationLinkToNexusLink and back with runId', (t) => {
7878

7979
const nexusLink = convertNexusOperationLinkToNexusLink(opLink);
8080
t.is(nexusLink.type, NEXUS_OPERATION_TYPE);
81-
t.is(nexusLink.url.toString(), 'temporal:///namespaces/ns/nexus-operations/op-123?runID=run-456');
81+
t.is(nexusLink.url.toString(), 'temporal:///namespaces/ns/nexus-operations/op-123/run-456/details');
8282

8383
const roundTrip = convertNexusLinkToTemporalLink(nexusLink);
8484
t.deepEqual(roundTrip, { nexusOperation: opLink });
@@ -88,17 +88,21 @@ test('convertNexusOperationLinkToNexusLink escapes URL path components', (t) =>
8888
const opLink = {
8989
namespace: 'name/space',
9090
operationId: 'operation id',
91+
runId: 'run/id',
9192
};
9293

9394
const nexusLink = convertNexusOperationLinkToNexusLink(opLink);
94-
t.is(nexusLink.url.toString(), 'temporal:///namespaces/name%2Fspace/nexus-operations/operation%20id');
95+
t.is(
96+
nexusLink.url.toString(),
97+
'temporal:///namespaces/name%2Fspace/nexus-operations/operation%20id/run%2Fid/details'
98+
);
9599

96100
const roundTrip = convertNexusLinkToTemporalLink(nexusLink);
97101
t.deepEqual(roundTrip, {
98102
nexusOperation: {
99103
namespace: 'name/space',
100104
operationId: 'operation id',
101-
runId: null,
105+
runId: 'run/id',
102106
},
103107
});
104108
});
@@ -181,6 +185,15 @@ test('throws on missing required fields', (t) => {
181185
}),
182186
{ instanceOf: TypeError }
183187
);
188+
t.throws(
189+
() =>
190+
convertNexusOperationLinkToNexusLink({
191+
namespace: 'ns',
192+
operationId: 'op-123',
193+
runId: '',
194+
}),
195+
{ instanceOf: TypeError }
196+
);
184197
});
185198

186199
test('throws on invalid URL scheme', (t) => {

packages/nexus/src/link-converter.ts

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ const LINK_EVENT_ID_PARAM = 'eventID';
1313
const LINK_EVENT_TYPE_PARAM = 'eventType';
1414
const LINK_REQUEST_ID_PARAM = 'requestID';
1515
const LINK_REFERENCE_TYPE_KEY = 'referenceType';
16-
const LINK_RUN_ID_KEY = 'runID';
1716

1817
const EVENT_REFERENCE_TYPE = 'EventReference';
1918
const REQUEST_ID_REFERENCE_TYPE = 'RequestIdReference';
@@ -77,22 +76,16 @@ export function convertWorkflowEventLinkToNexusLink(we: WorkflowEventLink): Nexu
7776
}
7877

7978
export function convertNexusOperationLinkToNexusLink(opLink: NexusOperationLink): NexusLink {
80-
if (!opLink.namespace || !opLink.operationId) {
81-
throw new TypeError('Missing required fields: namespace, or operationId');
79+
if (!opLink.namespace || !opLink.operationId || !opLink.runId) {
80+
throw new TypeError('Missing required fields: namespace, operationId, or runId');
8281
}
8382

8483
const url = new URL(
8584
`temporal:///namespaces/${encodeURIComponent(opLink.namespace)}/nexus-operations/${encodeURIComponent(
8685
opLink.operationId
87-
)}`
86+
)}/${encodeURIComponent(opLink.runId)}/details`
8887
);
8988

90-
if (opLink.runId != null) {
91-
const searchParams = new URLSearchParams();
92-
searchParams.set(LINK_RUN_ID_KEY, opLink.runId);
93-
url.search = searchParams.toString();
94-
}
95-
9689
return {
9790
url,
9891
type: NEXUS_OPERATION_TYPE,
@@ -132,16 +125,18 @@ export function convertNexusLinkToWorkflowEventLink(link: NexusLink): WorkflowEv
132125
}
133126

134127
function convertNexusLinkToNexusOperationLink(link: NexusLink): NexusOperationLink {
135-
// /namespaces/:namespace/nexus-operations/:operationId?runId=:runId
128+
// /namespaces/:namespace/nexus-operations/:operationId/:runId/details
136129
const parts = link.url.pathname.split('/');
137-
if (parts.length !== 5 || parts[1] !== 'namespaces' || parts[3] !== 'nexus-operations') {
130+
if (parts.length !== 7 || parts[1] !== 'namespaces' || parts[3] !== 'nexus-operations' || parts[6] !== 'details') {
138131
throw new TypeError(`Invalid URL path: ${link.url}`);
139132
}
140133
const namespace = decodeURIComponent(parts[2]!);
141134
const operationId = decodeURIComponent(parts[4]!);
135+
const runId = decodeURIComponent(parts[5]!);
142136

143-
const query = link.url.searchParams;
144-
const runId = query.get(LINK_RUN_ID_KEY);
137+
if (!namespace || !operationId || !runId) {
138+
throw new TypeError('Missing required fields: namespace, operationId, or runId');
139+
}
145140

146141
return {
147142
namespace,

packages/test/src/test-nexus-standalone.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { randomUUID } from 'crypto';
2+
import type { ExecutionContext } from 'ava';
23
import * as nexus from 'nexus-rpc';
4+
import { temporal } from '@temporalio/proto';
35
import type { NexusOperationExecutionCount } from '@temporalio/client';
46
import {
57
Client,
@@ -20,9 +22,12 @@ import * as temporalnexus from '@temporalio/nexus';
2022
import * as workflow from '@temporalio/workflow';
2123
import { CancelledFailure, TerminatedFailure, ApplicationFailure, SearchAttributeType } from '@temporalio/common';
2224
import { generateWorkflowRunOperationToken } from '@temporalio/nexus/lib/token';
25+
import type { Context } from './helpers-integration';
2326
import { helpers, makeTestFunction } from './helpers-integration';
2427
import { waitUntil } from './helpers';
2528

29+
const { EventType } = temporal.api.enums.v1;
30+
2631
const test = makeTestFunction({
2732
workflowsPath: __filename,
2833
workflowEnvironmentOpts: {
@@ -97,6 +102,37 @@ function makeTestHandler() {
97102
};
98103
}
99104

105+
function assertWorkflowCallbackLinksToNexusOperation(
106+
t: ExecutionContext<Context>,
107+
callbacks: temporal.api.workflow.v1.ICallbackInfo[] | null | undefined,
108+
namespace: string,
109+
operationId: string,
110+
operationRunId: string
111+
): void {
112+
const nexusOperationLink = callbacks
113+
?.flatMap((callbackInfo) => callbackInfo.callback?.links ?? [])
114+
.find((link) => link.nexusOperation?.operationId === operationId)?.nexusOperation;
115+
t.truthy(nexusOperationLink, 'expected workflow completion callback to link to the standalone Nexus operation');
116+
117+
t.is(nexusOperationLink?.namespace, namespace);
118+
t.is(nexusOperationLink?.runId, operationRunId);
119+
}
120+
121+
function assertOperationLinksToWorkflowStarted(
122+
t: ExecutionContext<Context>,
123+
links: temporal.api.common.v1.ILink[] | null | undefined,
124+
namespace: string,
125+
workflowId: string,
126+
workflowRunId: string
127+
): void {
128+
const workflowLink = links?.find((link) => link.workflowEvent?.workflowId === workflowId)?.workflowEvent;
129+
t.truthy(workflowLink, 'expected Nexus operation to link to the started workflow');
130+
131+
t.is(workflowLink?.namespace, namespace);
132+
t.is(workflowLink?.runId, workflowRunId);
133+
t.is(workflowLink?.eventRef?.eventType, EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED);
134+
}
135+
100136
test('start sync operation and get result', async (t) => {
101137
const { createWorker, registerNexusEndpoint } = helpers(t);
102138
const { endpointName } = await registerNexusEndpoint();
@@ -142,6 +178,22 @@ test('start async operation and poll result', async (t) => {
142178

143179
const result = await handle.result();
144180
t.is(result, 'async-hello');
181+
182+
const [workflowDesc, operationDesc] = await Promise.all([wfHandle.describe(), handle.describe()]);
183+
assertWorkflowCallbackLinksToNexusOperation(
184+
t,
185+
workflowDesc.raw.callbacks,
186+
t.context.env.client.options.namespace,
187+
operationDesc.operationId,
188+
operationDesc.runId
189+
);
190+
assertOperationLinksToWorkflowStarted(
191+
t,
192+
operationDesc.raw.links,
193+
t.context.env.client.options.namespace,
194+
wfHandle.workflowId,
195+
workflowDesc.runId
196+
);
145197
});
146198
});
147199

0 commit comments

Comments
 (0)