@servicenow/sdk-build-plugins 4.3.0 → 4.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/acl-plugin.js +2 -0
  2. package/dist/acl-plugin.js.map +1 -1
  3. package/dist/column/column-to-record.d.ts +10 -3
  4. package/dist/column/column-to-record.js +44 -7
  5. package/dist/column/column-to-record.js.map +1 -1
  6. package/dist/column-plugin.d.ts +3 -1
  7. package/dist/column-plugin.js +11 -11
  8. package/dist/column-plugin.js.map +1 -1
  9. package/dist/flow/plugins/flow-instance-plugin.js +285 -10
  10. package/dist/flow/plugins/flow-instance-plugin.js.map +1 -1
  11. package/dist/flow/plugins/flow-trigger-instance-plugin.js +21 -7
  12. package/dist/flow/plugins/flow-trigger-instance-plugin.js.map +1 -1
  13. package/dist/flow/utils/flow-constants.d.ts +7 -0
  14. package/dist/flow/utils/flow-constants.js +12 -5
  15. package/dist/flow/utils/flow-constants.js.map +1 -1
  16. package/dist/flow/utils/service-catalog.d.ts +47 -0
  17. package/dist/flow/utils/service-catalog.js +137 -0
  18. package/dist/flow/utils/service-catalog.js.map +1 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/now-attach-plugin.js +7 -10
  21. package/dist/now-attach-plugin.js.map +1 -1
  22. package/dist/now-ref-plugin.js +1 -1
  23. package/dist/now-ref-plugin.js.map +1 -1
  24. package/dist/server-module-plugin/index.d.ts +10 -0
  25. package/dist/server-module-plugin/index.js +45 -55
  26. package/dist/server-module-plugin/index.js.map +1 -1
  27. package/dist/service-catalog/sc-record-producer-plugin.js +1 -0
  28. package/dist/service-catalog/sc-record-producer-plugin.js.map +1 -1
  29. package/dist/service-catalog/service-catalog-base.js +2 -2
  30. package/dist/service-catalog/service-catalog-base.js.map +1 -1
  31. package/dist/service-catalog/service-catalog-diagnostics.js +4 -1
  32. package/dist/service-catalog/service-catalog-diagnostics.js.map +1 -1
  33. package/dist/service-catalog/shape-to-record.d.ts +1 -0
  34. package/dist/service-catalog/shape-to-record.js +4 -1
  35. package/dist/service-catalog/shape-to-record.js.map +1 -1
  36. package/dist/service-catalog/utils.d.ts +10 -0
  37. package/dist/service-catalog/utils.js +72 -0
  38. package/dist/service-catalog/utils.js.map +1 -1
  39. package/dist/static-content-plugin.js +25 -2
  40. package/dist/static-content-plugin.js.map +1 -1
  41. package/dist/table-plugin.js +16 -13
  42. package/dist/table-plugin.js.map +1 -1
  43. package/dist/ui-page-plugin.js +832 -19
  44. package/dist/ui-page-plugin.js.map +1 -1
  45. package/package.json +5 -5
  46. package/src/acl-plugin.ts +2 -0
  47. package/src/column/column-to-record.ts +54 -8
  48. package/src/column-plugin.ts +28 -12
  49. package/src/flow/plugins/flow-instance-plugin.ts +364 -13
  50. package/src/flow/plugins/flow-trigger-instance-plugin.ts +25 -7
  51. package/src/flow/utils/flow-constants.ts +13 -4
  52. package/src/flow/utils/service-catalog.ts +174 -0
  53. package/src/index.ts +0 -1
  54. package/src/now-attach-plugin.ts +10 -11
  55. package/src/now-ref-plugin.ts +1 -1
  56. package/src/server-module-plugin/index.ts +59 -69
  57. package/src/service-catalog/sc-record-producer-plugin.ts +1 -1
  58. package/src/service-catalog/service-catalog-base.ts +2 -2
  59. package/src/service-catalog/service-catalog-diagnostics.ts +4 -1
  60. package/src/service-catalog/shape-to-record.ts +6 -2
  61. package/src/service-catalog/utils.ts +93 -0
  62. package/src/static-content-plugin.ts +25 -2
  63. package/src/table-plugin.ts +30 -14
  64. package/src/ui-page-plugin.ts +1063 -20
@@ -0,0 +1,174 @@
1
+ import type { Database, Record, Shape, Source } from '@servicenow/sdk-build-core'
2
+ import {
3
+ ArrayShape,
4
+ IdentifierShape,
5
+ PropertyAccessShape,
6
+ TemplateExpressionShape,
7
+ TemplateSpanShape,
8
+ isGUID,
9
+ } from '@servicenow/sdk-build-core'
10
+ import { getIdentifierFromRecord } from './utils'
11
+ import {
12
+ CATALOG_VARIABLE_ACTIONS,
13
+ CORE_ACTIONS_SYS_ID_NAME_MAP,
14
+ CATALOG_ITEM_TABLE,
15
+ CATALOG_VARIABLE_TABLE,
16
+ } from './flow-constants'
17
+
18
+ /**
19
+ * Service Catalog Utility Functions
20
+ *
21
+ * This module contains utilities for transforming catalog item and catalog variable
22
+ * data between XML and Fluent formats, including:
23
+ * - Checking if an action is catalog-related
24
+ * - Finding catalog items by sys_id
25
+ * - Creating template expressions for catalog item references
26
+ * - Converting slushbucket format to catalog variable arrays
27
+ */
28
+
29
+ /**
30
+ * Check if an action definition is a catalog-related action (getCatalogVariables or createCatalogTask).
31
+ * @param actionDefinition - The action definition shape
32
+ * @returns true if the action is getCatalogVariables or createCatalogTask, false otherwise
33
+ */
34
+ export function isCatalogAction(actionDefinition: Shape | undefined): boolean {
35
+ const actionName = CORE_ACTIONS_SYS_ID_NAME_MAP[actionDefinition?.getValue() as string]
36
+ return actionName ? CATALOG_VARIABLE_ACTIONS.includes(actionName) : false
37
+ }
38
+
39
+ /**
40
+ * Helper function to find catalog item record by sys_id from database.
41
+ * @param sysId - The sys_id of the catalog item (must be 32 characters)
42
+ * @param database - Database instance to query
43
+ * @returns Catalog item record or undefined if not found
44
+ */
45
+ export function findCatalogItemBySysId(sysId: string | undefined, database: Database | undefined): Record | undefined {
46
+ if (!database || !sysId || !isGUID(sysId)) {
47
+ return undefined
48
+ }
49
+ return database.get(CATALOG_ITEM_TABLE, sysId)
50
+ }
51
+
52
+ /**
53
+ * Helper function to create a template expression from an identifier.
54
+ * Creates: `${identifierName}`
55
+ */
56
+ export function createTemplateExpressionFromIdentifier(
57
+ identifierName: string,
58
+ source: Source
59
+ ): TemplateExpressionShape {
60
+ return new TemplateExpressionShape({
61
+ source,
62
+ literalText: '',
63
+ spans: [
64
+ new TemplateSpanShape({
65
+ source,
66
+ expression: new IdentifierShape({ source, name: identifierName }),
67
+ literalText: '',
68
+ }),
69
+ ],
70
+ })
71
+ }
72
+
73
+ /**
74
+ * Helper function to create a PropertyAccessShape for a catalog variable reference.
75
+ * Creates: catalogItemIdentifierName.variables.varName
76
+ */
77
+ export function createCatalogVariablePropertyAccess(
78
+ catalogItemRecord: Record,
79
+ catalogItemIdentifierName: string,
80
+ varName: string,
81
+ source: Source
82
+ ): PropertyAccessShape {
83
+ const catalogItemIdentifier = new IdentifierShape({
84
+ source: catalogItemRecord.getOriginalNode() || source,
85
+ name: catalogItemIdentifierName,
86
+ value: catalogItemRecord,
87
+ })
88
+ const variablesIdentifier = new IdentifierShape({
89
+ source,
90
+ name: 'variables',
91
+ })
92
+ const varNameIdentifier = new IdentifierShape({
93
+ source,
94
+ name: varName,
95
+ })
96
+
97
+ return new PropertyAccessShape({
98
+ source,
99
+ elements: [catalogItemIdentifier, variablesIdentifier, varNameIdentifier],
100
+ })
101
+ }
102
+
103
+ /**
104
+ * Convert slushbucket format (comma-separated sys_id:name pairs) to an array of catalog variable references.
105
+ * Example: "abc123:email,def456:html" → [catalogItem.variables.email, catalogItem.variables.html]
106
+ *
107
+ * @param slushBucketValue - Slushbucket string from XML
108
+ * @param catalogItemRecord - Catalog item record to look up variables
109
+ * @param source - Source for creating shapes
110
+ * @param actionInstanceUUID - UUID of the action instance (for label_cache lookup)
111
+ * @param flowRecord - Flow record containing label_cache
112
+ * @returns ArrayShape of PropertyAccessShape elements or undefined if conversion fails
113
+ */
114
+ export function convertSlushBucketToCatalogVariableArray(
115
+ slushBucketValue: string,
116
+ catalogItemRecord: Record | undefined,
117
+ source: Source
118
+ ): ArrayShape | undefined {
119
+ if (!catalogItemRecord) {
120
+ return undefined
121
+ }
122
+
123
+ const variableShapes: PropertyAccessShape[] = []
124
+ const catalogItemIdentifierName = getIdentifierFromRecord(catalogItemRecord) || 'catalogItem'
125
+
126
+ //use slushbucket sys_ids to look up variable names
127
+ if (!slushBucketValue || !slushBucketValue.trim()) {
128
+ return undefined
129
+ }
130
+
131
+ const entries = slushBucketValue
132
+ .split(',')
133
+ .map((entry) => entry.trim())
134
+ .filter(Boolean)
135
+
136
+ // Build a map of sysId → record by iterating allRecords once
137
+ const variableRecordBySysId = new Map<string, Record>()
138
+ for (const rec of catalogItemRecord.flat()) {
139
+ if (rec.getTable() === CATALOG_VARIABLE_TABLE) {
140
+ variableRecordBySysId.set(rec.getId().getValue(), rec)
141
+ }
142
+ }
143
+
144
+ for (const entry of entries) {
145
+ const [sysId] = entry.split(':')
146
+ if (!sysId || !sysId.trim()) {
147
+ continue
148
+ }
149
+ const variableRecord = variableRecordBySysId.get(sysId)
150
+
151
+ if (variableRecord) {
152
+ const varName = variableRecord.get('name')?.asString()?.getValue()
153
+ if (varName) {
154
+ // Create PropertyAccessShape: catalogItemIdentifierName.variables.varName
155
+ const propertyAccess = createCatalogVariablePropertyAccess(
156
+ catalogItemRecord,
157
+ catalogItemIdentifierName,
158
+ varName,
159
+ source
160
+ )
161
+ variableShapes.push(propertyAccess)
162
+ }
163
+ }
164
+ }
165
+
166
+ if (variableShapes.length === 0) {
167
+ return undefined
168
+ }
169
+
170
+ return new ArrayShape({
171
+ source,
172
+ elements: variableShapes,
173
+ })
174
+ }
package/src/index.ts CHANGED
@@ -58,7 +58,6 @@ export * from './ux-list-menu-config-plugin'
58
58
  export * from './workspace-plugin'
59
59
  export * from './dashboard/dashboard-plugin'
60
60
  export * from './applicability-plugin'
61
-
62
61
  // non-plugins
63
62
  export * from './atf/step-configs'
64
63
  export { REPACK_OUTPUT_DIR, checkModuleExists, resolveModule } from './repack'
@@ -214,17 +214,23 @@ export const NowAttachPlugin = Plugin.create({
214
214
  records: {
215
215
  '*': {
216
216
  async toFile(record, { config, database, factory, transform }) {
217
+ const entries = record.entries()
218
+ const hasAttachments =
219
+ entries.some(([, shape]) => isLazyValue(shape)) ||
220
+ Object.keys(record.properties()).some((field) => hasAssociatedRecords(record.get(field)))
221
+
222
+ if (!hasAttachments) {
223
+ return { success: false }
224
+ }
225
+
217
226
  const recordBuilder = unloadBuilder(config)
218
227
  const updateName = await transform.getUpdateName(record)
219
228
  const builder = recordBuilder.record(record, updateName)
220
- let attachmentsProcessed = false
221
229
 
222
- record
223
- .entries()
230
+ entries
224
231
  .sort(([a], [b]) => a.localeCompare(b)) // Sort keys to make outputs more deterministic
225
232
  .forEach(([prop, shape]) => {
226
233
  if (isLazyValue(shape)) {
227
- attachmentsProcessed = true
228
234
  builder.field(prop, shape.evaluate(record, prop))
229
235
  } else {
230
236
  builder.field(prop, shape)
@@ -234,7 +240,6 @@ export const NowAttachPlugin = Plugin.create({
234
240
  for (const field in record.properties()) {
235
241
  const shape = record.get(field)
236
242
  if (hasAssociatedRecords(shape)) {
237
- attachmentsProcessed = true
238
243
  for (const rec of await shape.createAssociatedRecords({
239
244
  parentRecord: record,
240
245
  factory,
@@ -249,12 +254,6 @@ export const NowAttachPlugin = Plugin.create({
249
254
  }
250
255
  }
251
256
 
252
- if (!attachmentsProcessed) {
253
- return {
254
- success: false,
255
- }
256
- }
257
-
258
257
  const claims = database
259
258
  .query('sys_claim')
260
259
  .filter((claim) => claim.get('metadata_update_name').equals(updateName))
@@ -24,7 +24,7 @@ export const NowRefPlugin = Plugin.create({
24
24
  .getArgument(1)
25
25
  .as(
26
26
  [StringShape, ObjectShape],
27
- 'The second argument to Now.ref() must be a GUID or a coalesce keys object'
27
+ 'The second argument to Now.ref() must be a GUID, a Now ID string, or a coalesce keys object'
28
28
  )
29
29
  const maybeKeys = callExpression.hasArgument(2)
30
30
  ? callExpression
@@ -19,7 +19,6 @@ import { SBOMBuilder } from './sbom-builder'
19
19
  import isEqual from 'lodash/isEqual'
20
20
  import { INVALID_XML_CHARACTERS, applyPathMappings } from '../utils'
21
21
  import zip from 'lodash/zip'
22
- import type { DependencyNode } from '@servicenow/sdk-repack'
23
22
 
24
23
  const GLUE_CODE_PREFIX = '// @fluent-module'
25
24
  const GLUE_CODE_META_REGEX = new RegExp(`^${GLUE_CODE_PREFIX} (.*);(true|false);(.*)`) // name;isDefault;path
@@ -32,7 +31,7 @@ const GLUE_CODE_WARNING = `
32
31
  // SDK from regenerating it. However, you will then be responsible for the
33
32
  // management of this code in your Fluent file.`
34
33
 
35
- const NODE_MODULES = 'node_modules'
34
+ export const NODE_MODULES = 'node_modules'
36
35
 
37
36
  /**
38
37
  * Check if a module matches any trusted module patterns.
@@ -250,41 +249,7 @@ function isValidRequireCall(callExpression: ts.CallExpression, requirePath: ts.S
250
249
  return isRequire && !isRelativePath
251
250
  }
252
251
 
253
- function buildParentPathMap(dependencyNodes: DependencyNode[]): { [key: string]: string[] } {
254
- const importerMap: { [key: string]: string[] } = {}
255
- for (const node of dependencyNodes) {
256
- let parent = node.parentPackage
257
-
258
- while (parent) {
259
- const importerMapKey = `${node.pkgName}@${node.updatedManifest.version}`
260
- if (!importerMap[importerMapKey]) {
261
- importerMap[importerMapKey] = []
262
- }
263
- if (parent.pkgName) {
264
- importerMap[importerMapKey].push(parent.pkgName)
265
- }
266
-
267
- const newParent = dependencyNodes.find((potentialParent) => {
268
- if (!parent || !parent.pkgName) {
269
- return false
270
- }
271
- if (potentialParent.updatedManifest.dependencies[parent!.pkgName]) {
272
- return potentialParent
273
- }
274
- return false
275
- })
276
- parent = newParent
277
- ? {
278
- pkgName: newParent.updatedManifest.name,
279
- version: newParent.updatedManifest.version,
280
- }
281
- : undefined
282
- }
283
- }
284
- return importerMap
285
- }
286
-
287
- class ModuleDependencyShape extends Shape {
252
+ export class ModuleDependencyShape extends Shape {
288
253
  private readonly moduleName: string
289
254
 
290
255
  constructor({
@@ -369,34 +334,16 @@ function generateSBOMContent(context: RecordContext) {
369
334
 
370
335
  function getModuleDependencyPath(
371
336
  config: NowConfig,
372
- module: { name: string; file: string; version: string; packageJson: Package; importerPath?: string[] }
337
+ module: { name: string; file: string; version: string; packageJson: Package; modulePath?: string }
373
338
  ) {
374
- const { name, file, packageJson, importerPath, version } = module
339
+ const { name, file, packageJson, modulePath, version } = module
375
340
 
376
341
  if (NowConfig.legacyPackageResolution(config)) {
377
342
  return NowConfig.moduleResolutionPath(config, packageJson, true, name, version, file)
378
343
  }
379
344
 
380
- if (config.hoistDependencies) {
381
- return NowConfig.moduleResolutionPath(config, packageJson, true, NODE_MODULES, name, file)
382
- }
383
-
384
- if (importerPath && importerPath.length > 0) {
385
- const pathSegments = [...importerPath].reverse()
386
- const pathSegmentsWithNodeModules: string[] = []
387
- pathSegments.forEach((segment) => {
388
- pathSegmentsWithNodeModules.push(NODE_MODULES)
389
- pathSegmentsWithNodeModules.push(segment)
390
- })
391
- return NowConfig.moduleResolutionPath(
392
- config,
393
- packageJson,
394
- true,
395
- ...pathSegmentsWithNodeModules,
396
- NODE_MODULES,
397
- name,
398
- file
399
- )
345
+ if (modulePath) {
346
+ return NowConfig.moduleResolutionPath(config, packageJson, true, modulePath, file)
400
347
  }
401
348
 
402
349
  return NowConfig.moduleResolutionPath(config, packageJson, true, NODE_MODULES, name, file)
@@ -511,7 +458,8 @@ export const ServerModulePlugin = Plugin.create({
511
458
  shape: ModuleDependencyShape,
512
459
  fileTypes: ['module'],
513
460
  // TODO: When managed cache is provided to plugins, cache dependencies that were already handled to avoid reprocessing
514
- async toRecord(shape, { packageJson, diagnostics, fs, logger, project, factory, config }) {
461
+ async toRecord(shape, context) {
462
+ const { packageJson, diagnostics, fs, logger, project, factory, config } = context
515
463
  if (config.type === 'configuration') {
516
464
  throw new Error(`Modules cannot be used in a configuration project`)
517
465
  }
@@ -531,22 +479,24 @@ export const ServerModulePlugin = Plugin.create({
531
479
  const dependencyNodes = await repack.execute({
532
480
  id,
533
481
  entry: entry ? [entry] : ['.'],
482
+ legacyPackageResolution: NowConfig.legacyPackageResolution(config),
534
483
  })
535
484
  if (!dependencyNodes) {
536
485
  throw new Error(`Failed to build dependency ${id}`)
537
486
  }
538
487
 
539
- let importerMap: { [key: string]: string[] } = {}
540
- if (!config.hoistDependencies) {
541
- importerMap = buildParentPathMap(dependencyNodes)
542
- }
543
-
544
488
  const modules: { id: string; path: string; content: string }[] = []
545
489
  const { Lint } = await import('../repack/lint/index.js')
546
490
  for (const node of dependencyNodes) {
547
- const { packagePath, files, updatedManifest, pkgName } = node
491
+ const { packagePath, files, updatedManifest, originalPath } = node
548
492
  const { name, version } = updatedManifest
549
493
 
494
+ const { modulePath, idPath } = buildDependencyPackagePath(
495
+ project.getRootDir(),
496
+ name,
497
+ version,
498
+ originalPath
499
+ )
550
500
  for (const file of files) {
551
501
  const fileContent = fs.readFileSync(pathModule.join(packagePath, file)).toString('utf-8')
552
502
  if (/(.js|.cjs|.mjs)$/.test(pathModule.extname(file))) {
@@ -557,15 +507,16 @@ export const ServerModulePlugin = Plugin.create({
557
507
  }
558
508
  }
559
509
 
560
- const importerPath = importerMap[`${pkgName}@${version}`]
561
510
  modules.push({
562
- id: `${name}@${version}/${file}`,
511
+ id: NowConfig.legacyPackageResolution(config)
512
+ ? `${name}@${version}/${file}`
513
+ : `${idPath}@${version}/${file}`,
563
514
  path: getModuleDependencyPath(config, {
564
515
  name,
565
516
  file,
566
517
  version,
567
518
  packageJson,
568
- ...(importerPath ? { importerPath } : {}),
519
+ modulePath,
569
520
  }),
570
521
  content: fileContent,
571
522
  })
@@ -635,3 +586,42 @@ export const ServerModulePlugin = Plugin.create({
635
586
  },
636
587
  ],
637
588
  })
589
+
590
+ /**
591
+ * Builds a relative dependency package path from an absolute path.
592
+ * Handles special cases for monorepo dependencies and pnpm-style paths.
593
+ *
594
+ * @param rootDir - The project root directory
595
+ * @param name - The package name
596
+ * @param version - The package version
597
+ * @param path - The absolute path to the package (optional)
598
+ * @returns Relative path suitable for module resolution
599
+ */
600
+ function buildDependencyPackagePath(
601
+ rootDir: string,
602
+ name: string,
603
+ version: string,
604
+ path?: string
605
+ ): {
606
+ modulePath: string
607
+ idPath: string
608
+ } {
609
+ let relative = pathModule.relative(rootDir, path ?? '')
610
+ const isMonorepoDependency = relative.includes(`../`)
611
+ const isPnpm = relative.includes('.pnpm')
612
+ if (isMonorepoDependency) {
613
+ // Treat monorepo node_modules as if they are inside app node_modules
614
+ relative = relative.replaceAll('../', '').replaceAll('+', '/')
615
+ }
616
+ if (isPnpm) {
617
+ // Do our best to turn this pnpm path into an npm path
618
+ relative = relative.replaceAll('+', '/').replaceAll(`node_modules/.pnpm/${name}@${version}/`, '')
619
+ }
620
+
621
+ // IDs will resemble the legacy paths and share sys_ids if possible
622
+ const idPath = relative.replaceAll('node_modules/', '').replaceAll('.pnpm/', '')
623
+ return {
624
+ modulePath: relative,
625
+ idPath,
626
+ }
627
+ }
@@ -162,7 +162,6 @@ export const CatalogItemRecordProducerPlugin = Plugin.create({
162
162
  if (!validateVariableNameConflicts(arg, diagnostics, 'RecordProducer')) {
163
163
  return { success: false }
164
164
  }
165
-
166
165
  const m2mRecords = await createSharedM2MRecords(callExpression, arg, recordProducer, factory)
167
166
 
168
167
  if (arg.get('variables').isDefined()) {
@@ -172,6 +171,7 @@ export const CatalogItemRecordProducerPlugin = Plugin.create({
172
171
  variablesConfig,
173
172
  factory,
174
173
  parent: recordProducer,
174
+ parentArg: arg,
175
175
  diagnostics,
176
176
  })
177
177
 
@@ -446,8 +446,8 @@ export function transformCatalogItemSpecificFieldsToRecord(arg: ObjectShape, $:
446
446
  .toCdata()
447
447
  .def(''),
448
448
  request_method: $.from('requestMethod')
449
- .map((v: Shape) => (v.ifString()?.getValue() === '' ? undefined : v.getValue()))
450
- .def('order'),
449
+ .map((v: Shape) => (v.ifString()?.getValue() === 'order' ? '' : v.getValue()))
450
+ .def(''),
451
451
  no_cart_v2: $.from('hideAddToCart').def(false),
452
452
  no_quantity_v2: $.from('hideQuantitySelector').def(false),
453
453
  no_delivery_time_v2: $.from('hideDeliveryTime').def(false),
@@ -7,7 +7,10 @@ import type { Diagnostics, ObjectShape } from '@servicenow/sdk-build-core'
7
7
  */
8
8
  export function validateFulfillmentProcessExclusivity(arg: ObjectShape, diagnostics: Diagnostics): boolean {
9
9
  const fields = ['executionPlan', 'flow', 'workflow'] as const
10
- const defined = fields.filter((f) => arg.get(f).isDefined() && !arg.get(f).ifString()?.isEmpty())
10
+ const defined = fields.filter((f) => {
11
+ const val = arg.get(f)
12
+ return val.isDefined() && (!!val.ifRecordId() || !val.ifString()?.isEmpty())
13
+ })
11
14
 
12
15
  if (defined.length > 1) {
13
16
  diagnostics.error(
@@ -7,7 +7,7 @@ import {
7
7
  } from '@servicenow/sdk-build-core'
8
8
  import { getVariableTypeFromName } from './variable-helper'
9
9
  import { VariableTypeName } from './variable-helper'
10
- import { convertRolesToString, getVisibilityId } from './utils'
10
+ import { convertRolesToString, getVisibilityId, validateFieldNameBelongsToTable } from './utils'
11
11
  import {
12
12
  validateMapToFieldRequiresField,
13
13
  validateMandatoryReadOnlyHidden,
@@ -20,8 +20,9 @@ export async function buildVariableRecords(options: {
20
20
  diagnostics: Diagnostics
21
21
  variablesConfig: ObjectShape
22
22
  parent: Record
23
+ parentArg?: ObjectShape
23
24
  }): Promise<Record[]> {
24
- const { variablesConfig, factory, parent, diagnostics } = options
25
+ const { variablesConfig, factory, parent, diagnostics, parentArg } = options
25
26
  const records: Record[] = []
26
27
  let catItemRecord: Record
27
28
  let variableSetRecord: Record
@@ -31,6 +32,9 @@ export async function buildVariableRecords(options: {
31
32
  if (parent?.getTable() === 'item_option_new_set') {
32
33
  variableSetRecord = parent
33
34
  }
35
+ if (parent?.getTable() === 'sc_cat_item_producer' && parentArg) {
36
+ validateFieldNameBelongsToTable(parentArg, diagnostics, 'RecordProducer')
37
+ }
34
38
 
35
39
  // Convert entries to array to use for-of loop
36
40
  const entries = Array.from(variablesConfig.entries())
@@ -1360,3 +1360,96 @@ export function resolveAndValidateVariableId(
1360
1360
 
1361
1361
  return variableId
1362
1362
  }
1363
+
1364
+ /**
1365
+ * Validates that each variable's 'field' value (when 'mapToField' is true) belongs to the record producer's target table.
1366
+ * Resolves the table record from arg.get('table'), collects field names from sys_documentation descendants via .flat(),
1367
+ * and checks each variable's 'field' against those keys.
1368
+ * @param arg - The ObjectShape containing the record producer configuration
1369
+ * @param diagnostics - Diagnostics instance for reporting errors
1370
+ * @param context - Context where the validation is being performed ('RecordProducer')
1371
+ * @returns True if all mapped fields belong to the table, false otherwise
1372
+ */
1373
+ export function validateFieldNameBelongsToTable(arg: ObjectShape, diagnostics: Diagnostics, context: string): boolean {
1374
+ if (!arg.get('variables').isDefined()) {
1375
+ return true
1376
+ }
1377
+
1378
+ // Resolve the table record from arg.get('table')
1379
+ // table can be a direct record reference (IdentifierShape) or a plain string table name
1380
+ const tableShape = arg.get('table')
1381
+
1382
+ let tableRecord: Record | undefined
1383
+
1384
+ if (tableShape.isRecord()) {
1385
+ tableRecord = tableShape.asRecord()
1386
+ } else if (tableShape.is(IdentifierShape)) {
1387
+ const resolved = tableShape.as(IdentifierShape).resolve(true)
1388
+ if (resolved?.isRecord()) {
1389
+ tableRecord = resolved.asRecord()
1390
+ }
1391
+ }
1392
+
1393
+ // Collect field names from sys_documentation descendants via .flat() (only when table resolves to a record)
1394
+ let tableFieldSet: Set<string> | undefined
1395
+
1396
+ if (tableRecord) {
1397
+ const tableFields = tableRecord
1398
+ .flat()
1399
+ .filter((r: Record) => r.getTable() === 'sys_dictionary')
1400
+ .map((r: Record) => r.get('element')?.ifString()?.getValue())
1401
+ .filter((element): element is string => !!element)
1402
+
1403
+ if (tableFields.length > 0) {
1404
+ tableFieldSet = new Set(tableFields)
1405
+ }
1406
+ }
1407
+
1408
+ // Iterate over variables and validate field names for those with mapToField: true
1409
+ const variablesConfig = arg.get('variables').asObject()
1410
+ const entries = Array.from(variablesConfig.entries())
1411
+
1412
+ // Track which fields have already been mapped to detect duplicates (always enforced)
1413
+ const mappedFields = new Map<string, string>() // field -> first variableKey that mapped it
1414
+
1415
+ for (const [variableKey, value] of entries) {
1416
+ const callExpr = value.as(CallExpressionShape)
1417
+ const config = callExpr.getArgument(0).asObject()
1418
+
1419
+ const mapToField = config.get('mapToField')
1420
+ const isMapToFieldTrue = mapToField.isDefined() && mapToField.ifBoolean()?.getValue() === true
1421
+
1422
+ if (!isMapToFieldTrue) {
1423
+ continue
1424
+ }
1425
+
1426
+ const fieldShape = config.get('field')
1427
+ const fieldValue = fieldShape.isDefined() ? fieldShape.ifString()?.getValue() : undefined
1428
+
1429
+ if (!fieldValue) {
1430
+ continue
1431
+ }
1432
+
1433
+ // Check field belongs to table (only when table resolved to a record with sys_documentation)
1434
+ if (tableFieldSet && !tableFieldSet.has(fieldValue)) {
1435
+ diagnostics.error(
1436
+ fieldShape,
1437
+ `${context} variable '${variableKey}': field '${fieldValue}' does not belong to the target table. Valid fields are: ${[...tableFieldSet].join(', ')}.`
1438
+ )
1439
+ return false
1440
+ }
1441
+
1442
+ // Check if this field is already mapped by another variable (always enforced)
1443
+ const existingVariable = mappedFields.get(fieldValue)
1444
+ if (existingVariable) {
1445
+ diagnostics.error(
1446
+ fieldShape,
1447
+ `${context} variable '${variableKey}': field '${fieldValue}' is already mapped by variable '${existingVariable}'. Each table field can only be mapped by one variable.`
1448
+ )
1449
+ return false
1450
+ }
1451
+ mappedFields.set(fieldValue, variableKey)
1452
+ }
1453
+
1454
+ return true
1455
+ }
@@ -71,6 +71,20 @@ const attachmentRelationships = {
71
71
  },
72
72
  }
73
73
 
74
+ const sourceArtifactRelationships = {
75
+ sn_glider_source_artifact_m2m: {
76
+ via: 'application_file',
77
+ descendant: true,
78
+ relationships: {
79
+ sn_glider_source_artifact: {
80
+ via: 'source_artifact',
81
+ inverse: true,
82
+ descendant: true,
83
+ },
84
+ },
85
+ },
86
+ }
87
+
74
88
  const toNoOpShape = (record: Record) => {
75
89
  return { success: true, value: Shape.noOp(record) }
76
90
  }
@@ -87,9 +101,14 @@ export const StaticContentPlugin = Plugin.create({
87
101
  toShape: toNoOpShape,
88
102
  },
89
103
  sys_ux_lib_asset: {
90
- relationships: attachmentRelationships,
104
+ coalesce: ['name'],
105
+ relationships: { ...attachmentRelationships, ...sourceArtifactRelationships },
91
106
  toShape: toNoOpShape,
92
- toFile: multipleRecordsToFile,
107
+ toFile: async (mainRecord, context) => {
108
+ const existingRelated = mainRecord.flat().slice(1)
109
+ const m2mRecords = context.descendants.query('sn_glider_source_artifact_m2m')
110
+ return multipleRecordsToFile(mainRecord.with(...existingRelated, ...m2mRecords), context)
111
+ },
93
112
  },
94
113
  db_image: {
95
114
  relationships: attachmentRelationships,
@@ -142,6 +161,10 @@ export const StaticContentPlugin = Plugin.create({
142
161
  })
143
162
  if (mimeType === 'text/html') {
144
163
  // This content will be handled by the UiPage referencing it
164
+ } else if (relativePath.endsWith('.ui-source-manifest.json')) {
165
+ // Build-time manifest produced by the uiPageSourceManifest rollup plugin.
166
+ // Consumed by UiPage during build to determine which source files to include
167
+ // in the source artifact record. Not a deployable asset.
145
168
  } else if (mimeType === 'application/javascript') {
146
169
  tableName = 'sys_ux_lib_asset'
147
170
  assetName = pathModule.join(