@servicenow/sdk-build-core 4.5.0 → 4.6.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.
@@ -12,7 +12,7 @@ import { Database, DiffDatabase } from './database'
12
12
  import type { Context as BaseContext } from './context'
13
13
  import { getFileType, isSNScope } from '../util'
14
14
  import { Cache } from './cache'
15
- import { NOW_FILE_EXTENSION } from '..'
15
+ import { NOW_FILE_EXTENSION, NowConfig } from '..'
16
16
  import { path } from '@servicenow/sdk-build-core'
17
17
 
18
18
  type Context = Omit<BaseContext, 'keys'>
@@ -66,17 +66,8 @@ export type Relationship = {
66
66
  * properties, where each is a tuple: [columnName, regexPattern]. The regex extracts
67
67
  * values from the respective columns using capturing groups, and records are related
68
68
  * if all captured groups match between parent and child
69
- * - Function: Takes the parent and child records as arguments and returns a boolean to
70
- * indicate whether the records are related or not (WARNING: Do not use this
71
- * unless absolutely necessary as it can negatively impact performance and
72
- * cannot be serialized by the relationship resolver, requiring changes to
73
- * the REST API it invokes)
74
69
  */
75
- via:
76
- | string
77
- | { [column: string]: string }
78
- | ((parent: Record, child: Record) => boolean)
79
- | { parent: [string, string]; child: [string, string] }[]
70
+ via: string | { [column: string]: string } | { parent: [string, string]; child: [string, string] }[]
80
71
 
81
72
  /**
82
73
  * When true, the relationship direction is reversed - the parent references the child
@@ -657,16 +648,22 @@ export class Plugin {
657
648
  }
658
649
 
659
650
  getDescendants(parent: Record, database: Database): Record[] {
651
+ return this.traverseDescendants(parent, database)
652
+ }
653
+
654
+ private traverseDescendants(parent: Record, database: Database, visited: Set<string> = new Set()): Record[] {
655
+ visited.add(parent.getId().getValue())
656
+
660
657
  const descendantRelationships = Object.entries(this.relationships[parent.getTable()] ?? {}).filter(
661
658
  ([, { descendant }]) => descendant
662
659
  )
663
-
664
660
  const descendants: Record[] = []
665
661
  const push = (children: Record | Record[] | undefined) => {
666
662
  for (const child of [children].flat()) {
667
- if (child && parent !== child) {
663
+ if (child && !visited.has(child.getId().getValue())) {
664
+ visited.add(child.getId().getValue())
668
665
  descendants.push(child)
669
- descendants.push(...this.getDescendants(child, database))
666
+ descendants.push(...this.traverseDescendants(child, database, visited))
670
667
  }
671
668
  }
672
669
  }
@@ -682,8 +679,6 @@ export class Plugin {
682
679
  } else {
683
680
  push(database.query(childTable, { [via]: parent.toString().getValue() }))
684
681
  }
685
- } else if (typeof via === 'function') {
686
- push(database.query(childTable).filter((child) => via(parent, child)))
687
682
  } else if (Array.isArray(via)) {
688
683
  push(
689
684
  database.query(childTable).filter((child) => {
@@ -692,16 +687,26 @@ export class Plugin {
692
687
  parent: [pCol, pReg],
693
688
  child: [cCol, cReg],
694
689
  } = entry
695
- const parentValue = parent
696
- .get(pCol)
697
- .ifString()
698
- ?.asString(`Expected string value for column ${pCol} in regex-based via relationship`)
699
- .getValue()
700
- const childValue = child
701
- .get(cCol)
702
- .ifString()
703
- ?.asString(`Expected string value for column ${cCol} in regex-based via relationship`)
704
- .getValue()
690
+ const parentValue =
691
+ pCol === 'sys_id'
692
+ ? parent.getId().getValue()
693
+ : parent
694
+ .get(pCol)
695
+ .ifString()
696
+ ?.asString(
697
+ `Expected string value for column ${pCol} in regex-based via relationship`
698
+ )
699
+ .getValue()
700
+ const childValue =
701
+ cCol === 'sys_id'
702
+ ? child.getId().getValue()
703
+ : child
704
+ .get(cCol)
705
+ .ifString()
706
+ ?.asString(
707
+ `Expected string value for column ${cCol} in regex-based via relationship`
708
+ )
709
+ .getValue()
705
710
  if (!parentValue || !childValue) {
706
711
  return false
707
712
  }
@@ -952,6 +957,8 @@ export class Plugin {
952
957
  }): Promise<Result<Shape[]>> {
953
958
  let success = false
954
959
  const shapes: Shape[] = []
960
+ const unhandledRecords: Record[] = []
961
+ const unhandledRecordIds: Set<string> = new Set()
955
962
 
956
963
  for (const [table, { toShape }] of Object.entries(this.config.records ?? {})) {
957
964
  if (!toShape) {
@@ -1006,14 +1013,10 @@ export class Plugin {
1006
1013
  descendants: nonDeletedDescendants,
1007
1014
  })
1008
1015
 
1009
- // Track unhandled records to avoid marking them as handled
1010
- const unhandledRecords: Record[] = []
1011
- const unhandledRecordIds: string[] = []
1012
-
1013
1016
  if (result.success === 'partial') {
1014
1017
  for (const unhandledRecord of result.unhandledRecords) {
1015
1018
  unhandledRecords.push(unhandledRecord)
1016
- unhandledRecordIds.push(unhandledRecord.getId().getValue())
1019
+ unhandledRecordIds.add(unhandledRecord.getId().getValue())
1017
1020
  }
1018
1021
  }
1019
1022
 
@@ -1030,7 +1033,7 @@ export class Plugin {
1030
1033
 
1031
1034
  // Mark all descendants as handled
1032
1035
  for (const descendant of [...deletedDescendants, ...nonDeletedDescendants]) {
1033
- if (!unhandledRecordIds.includes(descendant.getId().getValue())) {
1036
+ if (!unhandledRecordIds.has(descendant.getId().getValue())) {
1034
1037
  // This descendent was known to the plugin because it is not deleted, and handling was deferred
1035
1038
  // delete_multiple should also be marked 'handled'
1036
1039
  if (descendant.getAction() !== 'DELETE') {
@@ -1045,17 +1048,17 @@ export class Plugin {
1045
1048
  }
1046
1049
  }
1047
1050
  }
1048
-
1049
- if (unhandledRecords.length > 0) {
1050
- context.logger.debug(
1051
- `Plugin ${this.getName()} deferred handling of records: ${unhandledRecords.map((r) => r.getTable() + '_' + r.getId().getValue()).join(', ')}`
1052
- )
1053
- return { success: 'partial', value: shapes, unhandledRecords }
1054
- }
1055
1051
  }
1056
1052
  }
1057
1053
  }
1058
1054
 
1055
+ if (unhandledRecords.length > 0) {
1056
+ context.logger.debug(
1057
+ `Plugin ${this.getName()} deferred handling of records: ${unhandledRecords.map((r) => r.getTable() + '_' + r.getId().getValue()).join(', ')}`
1058
+ )
1059
+ return { success: 'partial', value: shapes, unhandledRecords }
1060
+ }
1061
+
1059
1062
  return { success, value: shapes }
1060
1063
  }
1061
1064
 
@@ -1132,12 +1135,31 @@ export class Plugin {
1132
1135
  const filePath = path.normalize(record.getOriginalFilePath())
1133
1136
  if (isSNScope(context.config.scope) && filePath.endsWith(NOW_FILE_EXTENSION)) {
1134
1137
  const relativePath = path.relative(path.join(context.config.fluentDir, 'if'), filePath)
1135
- if (!relativePath.startsWith('..') && path.dirname(relativePath) !== '.') {
1138
+ const relativePathGenerated = path.relative(
1139
+ path.join(context.config.generatedDir, 'if'),
1140
+ filePath
1141
+ )
1142
+ if (
1143
+ !relativePath.startsWith('..') ||
1144
+ (!relativePathGenerated.startsWith('..') && path.dirname(relativePath) !== '.')
1145
+ ) {
1146
+ const subPath = relativePath.startsWith('..') ? relativePathGenerated : relativePath
1136
1147
  return {
1137
1148
  success: true,
1138
1149
  value: outputFiles.map((f) => ({
1139
1150
  ...f,
1140
- ifDirectoryPackage: path.join('if', path.dirname(relativePath)),
1151
+ ifDirectoryPackage: path.join('if', subPath.split(path.sep)[0]!),
1152
+ })),
1153
+ }
1154
+ }
1155
+
1156
+ const hosted = NowConfig.getHostedDirectory(context.config, filePath)
1157
+ if (hosted) {
1158
+ return {
1159
+ success: true,
1160
+ value: outputFiles.map((f) => ({
1161
+ ...f,
1162
+ hostedPluginDir: hosted,
1141
1163
  })),
1142
1164
  }
1143
1165
  }
@@ -1294,6 +1316,13 @@ export class Plugins {
1294
1316
  return this.plugins
1295
1317
  }
1296
1318
 
1319
+ remove(name: string): void {
1320
+ const index = this.plugins.findIndex((plugin) => plugin.getName() === name)
1321
+ if (index !== -1) {
1322
+ this.plugins.splice(index, 1)
1323
+ }
1324
+ }
1325
+
1297
1326
  prepend(...plugins: Plugin[]): void {
1298
1327
  this.plugins.unshift(...plugins)
1299
1328
  }
@@ -10,6 +10,14 @@ export type PostInstallTask = {
10
10
  /** Human-readable description for the --skip flag help text */
11
11
  skipFlagDescription: string
12
12
 
13
+ /**
14
+ * Controls which project type triggers this task.
15
+ * - `'app'` — only runs after installing a scoped app.
16
+ * - `'configuration'` — only runs after installing a configuration project.
17
+ * - `'both'` — runs after either install path.
18
+ */
19
+ runFor: 'app' | 'configuration' | 'both'
20
+
13
21
  /** Execute the post-install task */
14
22
  run(context: PostInstallContext): Promise<void>
15
23
  }
@@ -1170,7 +1170,10 @@ export class ObjectShape extends Shape<globalThis.Record<string, unknown>> {
1170
1170
  }
1171
1171
 
1172
1172
  static quotePropertyNameIfNeeded(name: string): string {
1173
- return name === '' || /[^\w$]/.test(name) ? `'${name}'` : name
1173
+ // Valid JS identifier (e.g. short_description, $id) no quotes needed
1174
+ // Pure numeric literal without leading zeros (e.g. 0, 100, 1.5) — valid as object key without quotes
1175
+ // Everything else (empty string, hyphens, digit-leading non-numeric chars, octal-like 007) — must be quoted
1176
+ return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) || /^(0|[1-9]\d*)(\.\d+)?$/.test(name) ? name : `'${name}'`
1174
1177
  }
1175
1178
  }
1176
1179
 
package/src/xml.ts CHANGED
@@ -1,7 +1,15 @@
1
1
  import { Record, RecordId, ResolvableShape, Shape } from './plugins'
2
2
  import { js2xml, type Element } from 'xml-js'
3
3
 
4
- export function unloadBuilder({ scope, scopeId, table }: { scope: string; scopeId: string; table?: string }) {
4
+ export function unloadBuilder({
5
+ scope,
6
+ scopeId,
7
+ table,
8
+ }: {
9
+ scope?: string | undefined
10
+ scopeId: string
11
+ table?: string
12
+ }) {
5
13
  const recordUpdateElements = []
6
14
  const xmlJsObj = {
7
15
  declaration: {
@@ -41,7 +49,8 @@ export function unloadBuilder({ scope, scopeId, table }: { scope: string; scopeI
41
49
  const rec = recordXml(recordUpdateElements, table, record.getId().getValue(), {
42
50
  attr: { action: record.getAction(), ...extraAttributes },
43
51
  })
44
- rec.field('sys_scope', scopeId, { display_value: scope })
52
+
53
+ scope ? rec.field('sys_scope', scopeId, { display_value: scope }) : rec.field('sys_scope', scopeId)
45
54
  rec.field('sys_update_name', updateName)
46
55
  return rec
47
56
  },