@servicenow/sdk-build-plugins 4.8.0 → 4.8.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 (45) hide show
  1. package/dist/flow/flow-logic/flow-logic-diagnostics.js +2 -1
  2. package/dist/flow/flow-logic/flow-logic-diagnostics.js.map +1 -1
  3. package/dist/flow/flow-logic/flow-logic-plugin.js.map +1 -1
  4. package/dist/flow/plugins/flow-action-definition-plugin.js +81 -16
  5. package/dist/flow/plugins/flow-action-definition-plugin.js.map +1 -1
  6. package/dist/flow/plugins/flow-definition-plugin.js +70 -7
  7. package/dist/flow/plugins/flow-definition-plugin.js.map +1 -1
  8. package/dist/flow/plugins/flow-instance-plugin.d.ts +35 -1
  9. package/dist/flow/plugins/flow-instance-plugin.js +240 -6
  10. package/dist/flow/plugins/flow-instance-plugin.js.map +1 -1
  11. package/dist/flow/plugins/step-instance-plugin.js +60 -0
  12. package/dist/flow/plugins/step-instance-plugin.js.map +1 -1
  13. package/dist/flow/post-install.d.ts +2 -1
  14. package/dist/flow/post-install.js +31 -4
  15. package/dist/flow/post-install.js.map +1 -1
  16. package/dist/flow/utils/complex-object-resolver.js +4 -2
  17. package/dist/flow/utils/complex-object-resolver.js.map +1 -1
  18. package/dist/flow/utils/datapill-transformer.d.ts +5 -72
  19. package/dist/flow/utils/datapill-transformer.js +199 -28
  20. package/dist/flow/utils/datapill-transformer.js.map +1 -1
  21. package/dist/flow/utils/flow-io-to-record.js +24 -15
  22. package/dist/flow/utils/flow-io-to-record.js.map +1 -1
  23. package/dist/flow/utils/flow-shapes.d.ts +7 -1
  24. package/dist/flow/utils/flow-shapes.js +19 -0
  25. package/dist/flow/utils/flow-shapes.js.map +1 -1
  26. package/dist/flow/utils/flow-variable-processor.d.ts +6 -6
  27. package/dist/flow/utils/flow-variable-processor.js +8 -8
  28. package/dist/flow/utils/flow-variable-processor.js.map +1 -1
  29. package/dist/index.d.ts +1 -1
  30. package/dist/index.js +2 -1
  31. package/dist/index.js.map +1 -1
  32. package/package.json +5 -5
  33. package/src/flow/flow-logic/flow-logic-diagnostics.ts +2 -1
  34. package/src/flow/flow-logic/flow-logic-plugin.ts +0 -1
  35. package/src/flow/plugins/flow-action-definition-plugin.ts +92 -25
  36. package/src/flow/plugins/flow-definition-plugin.ts +114 -8
  37. package/src/flow/plugins/flow-instance-plugin.ts +262 -6
  38. package/src/flow/plugins/step-instance-plugin.ts +73 -1
  39. package/src/flow/post-install.ts +36 -5
  40. package/src/flow/utils/complex-object-resolver.ts +4 -2
  41. package/src/flow/utils/datapill-transformer.ts +248 -36
  42. package/src/flow/utils/flow-io-to-record.ts +28 -14
  43. package/src/flow/utils/flow-shapes.ts +19 -0
  44. package/src/flow/utils/flow-variable-processor.ts +21 -10
  45. package/src/index.ts +1 -1
@@ -30,7 +30,8 @@ import {
30
30
  FLOW_OBJECT_API_NAME,
31
31
  FLOW_ARRAY_API_NAME,
32
32
  } from '../utils/flow-constants'
33
- import { normalizeInputValue } from './flow-instance-plugin'
33
+ import { normalizeInputValue, applyTemplateValueSubFieldScripts } from './flow-instance-plugin'
34
+ import { FDInlineScriptCallShape } from './inline-script-plugin'
34
35
  import { stripPillType, collectPillTypes } from '../utils/flow-pill-utils'
35
36
  import { ApprovalRulesShape, ApprovalDueDateShape } from '../utils/flow-shapes'
36
37
  import {
@@ -374,6 +375,9 @@ export const StepInstancePlugin = Plugin.create({
374
375
  sys_element_mapping: {
375
376
  coalesce: ['field', 'table', 'id'],
376
377
  },
378
+ sys_hub_input_scripts: {
379
+ coalesce: ['instance', 'input_name'],
380
+ },
377
381
  sys_hub_step_instance: {
378
382
  relationships: {
379
383
  sys_variable_value: {
@@ -396,6 +400,10 @@ export const StepInstancePlugin = Plugin.create({
396
400
  via: 'documentkey',
397
401
  descendant: true,
398
402
  },
403
+ sys_hub_input_scripts: {
404
+ via: 'instance',
405
+ descendant: true,
406
+ },
399
407
  },
400
408
  toShape(record, { descendants, logger }) {
401
409
  // Skip step instances that belong to an action definition —
@@ -491,6 +499,32 @@ export const StepInstancePlugin = Plugin.create({
491
499
  : builtInDef.name
492
500
  }
493
501
 
502
+ // Recover wfa.inlineScript() sub-fields from sys_hub_input_scripts descendants.
503
+ // Without this, TemplateValue fields with fd-scripted placeholders would round-trip
504
+ // as TemplateValue({ assignment_group: 'fd-scripted' }) instead of
505
+ // TemplateValue({ assignment_group: wfa.inlineScript('...') }).
506
+ const inputScripts = descendants.query('sys_hub_input_scripts')
507
+ for (const scriptRecord of inputScripts) {
508
+ const inputName = scriptRecord.get('input_name')?.asString()?.getValue()
509
+ const scriptJson = scriptRecord.get('script')?.asString()?.getValue()
510
+ if (!inputName || !scriptJson || configObj[inputName] === undefined) {
511
+ continue
512
+ }
513
+ try {
514
+ const scriptedFields = JSON.parse(scriptJson) as globalThis.Record<
515
+ string,
516
+ { scriptActive: boolean; script: string }
517
+ >
518
+ configObj[inputName] = applyTemplateValueSubFieldScripts(
519
+ configObj[inputName],
520
+ scriptedFields,
521
+ record
522
+ )
523
+ } catch {
524
+ // malformed script JSON — leave the existing value unchanged
525
+ }
526
+ }
527
+
494
528
  // Build step instance config (2nd arg): $id, label, error_handling_type
495
529
  const configProperties: globalThis.Record<string, unknown> = {
496
530
  $id: NowIdShape.from(record),
@@ -1079,6 +1113,9 @@ async function createVariableRecords(
1079
1113
  // Handle TemplateValueShape specially - serialize to ServiceNow format.
1080
1114
  // When TemplateValue contains datapills, the platform stores the entire encoded string
1081
1115
  // (with pills inline) in a single sys_element_mapping record with field=<parent input name>.
1116
+ // Scripted sub-fields (wfa.inlineScript()) use the 'fd-scripted' placeholder in the
1117
+ // encoded value and have their script content stored in a sys_hub_input_scripts record.
1118
+ const scriptedSubFields: globalThis.Record<string, { scriptActive: true; script: string }> = {}
1082
1119
  let actualValue: unknown
1083
1120
  if (valueShape.is(TemplateValueShape)) {
1084
1121
  const templateObj = (valueShape as TemplateValueShape).getTemplateValue()
@@ -1091,6 +1128,10 @@ async function createVariableRecords(
1091
1128
  collectPillTypes(pillString, pillTypeMap)
1092
1129
  entries.push(`${field}=${stripPillType(pillString)}`)
1093
1130
  hasPills = true
1131
+ } else if (fieldShape instanceof FDInlineScriptCallShape) {
1132
+ // Use fd-scripted placeholder; script content goes to sys_hub_input_scripts
1133
+ entries.push(`${field}=fd-scripted`)
1134
+ scriptedSubFields[field] = { scriptActive: true, script: fieldShape.getScriptContent() }
1094
1135
  } else if (fieldShape.is(TemplateExpressionShape)) {
1095
1136
  const resolved = resolveTemplateExpression(
1096
1137
  fieldShape as TemplateExpressionShape,
@@ -1111,6 +1152,8 @@ async function createVariableRecords(
1111
1152
 
1112
1153
  const encodedValue = entries.join('^')
1113
1154
 
1155
+ const hasScripts = Object.keys(scriptedSubFields).length > 0
1156
+
1114
1157
  if (hasPills) {
1115
1158
  // TemplateValue with datapills → single sys_element_mapping record
1116
1159
  // No sys_variable_value for this field — platform reads from element_mapping only
@@ -1126,6 +1169,19 @@ async function createVariableRecords(
1126
1169
  },
1127
1170
  })
1128
1171
  )
1172
+ if (hasScripts) {
1173
+ allRecords.push(
1174
+ await factory.createRecord({
1175
+ source: callExpression,
1176
+ table: 'sys_hub_input_scripts',
1177
+ properties: {
1178
+ instance: stepInstanceSysId,
1179
+ input_name: key,
1180
+ script: JSON.stringify(scriptedSubFields),
1181
+ },
1182
+ })
1183
+ )
1184
+ }
1129
1185
  return
1130
1186
  }
1131
1187
 
@@ -1236,6 +1292,22 @@ async function createVariableRecords(
1236
1292
  },
1237
1293
  })
1238
1294
  allRecords.push(record)
1295
+
1296
+ // For TemplateValue with scripted sub-fields (no datapills path),
1297
+ // create sys_hub_input_scripts record alongside the sys_variable_value
1298
+ if (Object.keys(scriptedSubFields).length > 0) {
1299
+ allRecords.push(
1300
+ await factory.createRecord({
1301
+ source: callExpression,
1302
+ table: 'sys_hub_input_scripts',
1303
+ properties: {
1304
+ instance: stepInstanceSysId,
1305
+ input_name: key,
1306
+ script: JSON.stringify(scriptedSubFields),
1307
+ },
1308
+ })
1309
+ )
1310
+ }
1239
1311
  })
1240
1312
  )
1241
1313
  return allRecords
@@ -1,4 +1,4 @@
1
- import type { PostInstallTask, PostInstallContext } from '@servicenow/sdk-build-core'
1
+ import type { PostInstallTask, PostInstallContext, RecordEntry } from '@servicenow/sdk-build-core'
2
2
 
3
3
  type ActivateFlowsResult = {
4
4
  status: string
@@ -16,8 +16,11 @@ type ActivateFlowsResult = {
16
16
  }>
17
17
  }
18
18
 
19
+ const FLOW_TABLE = 'sys_hub_flow'
20
+ const ACTION_TABLE = 'sys_hub_action_type_definition'
21
+
19
22
  async function activateFlows(context: PostInstallContext): Promise<void> {
20
- const { instanceClient, logger, config } = context
23
+ const { instanceClient, logger, config, recordIds } = context
21
24
  const { scopeId } = config
22
25
 
23
26
  if (!instanceClient) {
@@ -25,13 +28,28 @@ async function activateFlows(context: PostInstallContext): Promise<void> {
25
28
  return
26
29
  }
27
30
 
28
- logger.debug('Activating flows...')
31
+ const flowAndSubflowIds = recordIds?.[FLOW_TABLE] ?? []
32
+ const actionIds = recordIds?.[ACTION_TABLE] ?? []
33
+
34
+ if (flowAndSubflowIds.length === 0 && actionIds.length === 0) {
35
+ logger.debug('No flows, subflows, or actions found in project, skipping flow activation')
36
+ return
37
+ }
38
+
39
+ logger.debug(`Activating flows... (${flowAndSubflowIds.length} flow/subflow(s), ${actionIds.length} action(s))`)
29
40
 
30
41
  const response = await instanceClient.fetch(
31
42
  'api/now/wfa_fluent/activate_flows',
32
43
  {
33
44
  method: 'POST',
34
- headers: { Accept: 'application/json' },
45
+ headers: {
46
+ Accept: 'application/json',
47
+ 'Content-Type': 'application/json',
48
+ },
49
+ body: JSON.stringify({
50
+ flows: flowAndSubflowIds,
51
+ actions: actionIds,
52
+ }),
35
53
  },
36
54
  new URLSearchParams({ sysparm_transaction_scope: scopeId })
37
55
  )
@@ -40,7 +58,7 @@ async function activateFlows(context: PostInstallContext): Promise<void> {
40
58
  // Any other non-OK status (400, 401, 403, 500, etc.) is an error
41
59
  if (!response.ok && response.status !== 422) {
42
60
  const body = await response.json().catch(() => null)
43
- const msg = body?.error?.message ?? body?.message ?? response.statusText
61
+ const msg = body?.result?.error?.message ?? body?.error?.message ?? body?.message ?? response.statusText
44
62
 
45
63
  // Instances without the endpoint return 400 with "does not represent any resource"
46
64
  if (msg.includes('does not represent any resource')) {
@@ -84,6 +102,19 @@ async function activateFlows(context: PostInstallContext): Promise<void> {
84
102
  logger.warn(msg)
85
103
  }
86
104
 
105
+ export function getRecordIdsByTable(keysRegistry: Now.Internal.KeysRegistry): Record<string, RecordEntry[]> {
106
+ const result: Record<string, RecordEntry[]> = {}
107
+
108
+ for (const entry of Object.values(keysRegistry.explicit)) {
109
+ if (entry.deleted) {
110
+ continue
111
+ }
112
+ const entries = (result[entry.table] ??= [])
113
+ entries.push({ sys_id: entry.id, active: '', state: '' })
114
+ }
115
+ return result
116
+ }
117
+
87
118
  export const FlowActivationTask: PostInstallTask = {
88
119
  name: 'flow-activation',
89
120
  skipFlag: 'skipFlowActivation',
@@ -850,8 +850,10 @@ function buildCv(typeNode: unknown, valueNode: unknown): unknown {
850
850
  // Special handling for primitive arrays (schema: ["Any"], value: ["item1", "item2", ...])
851
851
  if (Array.isArray(typeNode) && Array.isArray(valueNode)) {
852
852
  if (typeNode.length === 1 && typeNode[0] === 'Any') {
853
- // Primitive array: return plain string values without $cv wrapping
854
- return valueNode.map((v) => String(v))
853
+ // Primitive array: preserve native types (string, number, boolean) without $cv wrapping.
854
+ // Do NOT force String(v) — that would convert [10,20,30] to ['10','20','30'] and
855
+ // [true,false] to ['true','false'], breaking the round-trip for IntegerColumn/BooleanColumn.
856
+ return valueNode
855
857
  }
856
858
  }
857
859
 
@@ -5,11 +5,18 @@ import {
5
5
  Shape,
6
6
  VariableStatementShape,
7
7
  ObjectShape,
8
+ gunzipSync,
8
9
  type Diagnostics,
9
10
  type Logger,
10
11
  } from '@servicenow/sdk-build-core'
11
12
  import { ArrowFunctionShape } from '../../arrow-function-plugin'
12
- import { FLOW_LOGIC, FLOW_LOGIC_PREFIX } from '../flow-logic/flow-logic-constants'
13
+ import {
14
+ DO_IN_PARALLEL_BLOCK_SYS_ID,
15
+ FLOW_LOGIC,
16
+ FLOW_LOGIC_PREFIX,
17
+ FLOW_LOGIC_SYS_ID_MAP,
18
+ TRY_CATCH_CATCH_SYS_ID,
19
+ } from '../flow-logic/flow-logic-constants'
13
20
 
14
21
  /**
15
22
  * Result of datapill transformation
@@ -22,7 +29,7 @@ export interface DatapillTransformResult {
22
29
  /**
23
30
  * Information about a flow instance shape for processing
24
31
  */
25
- export interface FlowInstanceShapeInfo {
32
+ interface FlowInstanceShapeInfo {
26
33
  callExpression: CallExpressionShape
27
34
  variableStatement: VariableStatementShape | CallExpressionShape
28
35
  }
@@ -57,7 +64,7 @@ export function buildUuidToIdentifierMap(recordToShapeMap: Map<Record, Shape>):
57
64
 
58
65
  for (const [record, instanceShape] of recordToShapeMap.entries()) {
59
66
  // Extract ui_id from the record (this is what datapills reference)
60
- const uiId = record.get('ui_id')?.getValue() as string | undefined
67
+ const uiId = record.get('ui_id')?.ifString()?.getValue()
61
68
 
62
69
  if (!uiId) {
63
70
  continue
@@ -66,6 +73,14 @@ export function buildUuidToIdentifierMap(recordToShapeMap: Map<Record, Shape>):
66
73
  // Handle VariableStatementShape (actions/subflows with variable assignments)
67
74
  if (instanceShape instanceof VariableStatementShape) {
68
75
  const variableStatement = instanceShape.as(VariableStatementShape)
76
+ const initCallee =
77
+ variableStatement.getInitializer() instanceof CallExpressionShape
78
+ ? (variableStatement.getInitializer() as CallExpressionShape).getCallee()
79
+ : undefined
80
+ if (initCallee === FLOW_LOGIC.DO_IN_PARALLEL || initCallee === FLOW_LOGIC.TRY_CATCH) {
81
+ continue
82
+ }
83
+
69
84
  const variableName = variableStatement.getVariableName()
70
85
  const callExpression = variableStatement.getInitializer().as(CallExpressionShape)
71
86
 
@@ -119,6 +134,227 @@ export function buildUuidToIdentifierMap(recordToShapeMap: Map<Record, Shape>):
119
134
  return uuidToIdentifierMap
120
135
  }
121
136
 
137
+ /**
138
+ * Build a ui_id → ancestor-chain map for the given instances. Each entry's value is the set of all `ui_id`s in the
139
+ * record's ancestor chain, including the record itself. Useful when resolving pills: a source record that lives
140
+ * "inside the same branch" as the target action shares the target's branch `ui_id` in this set, and the bare
141
+ * identifier can be used (no outer-binding dot-walk needed).
142
+ */
143
+ function buildAncestorChainMap(allInstances: ReadonlyArray<Record>): Map<string, Set<string>> {
144
+ const recordByUiId = new Map<string, Record>()
145
+
146
+ for (const inst of allInstances) {
147
+ const ui = inst.get('ui_id')?.ifString()?.getValue()
148
+ if (ui) {
149
+ recordByUiId.set(ui, inst)
150
+ }
151
+ }
152
+
153
+ const chainByUiId = new Map<string, Set<string>>()
154
+
155
+ const computeChain = (uiId: string, seen: Set<string>): Set<string> => {
156
+ const cached = chainByUiId.get(uiId)
157
+ if (cached) {
158
+ return cached
159
+ }
160
+
161
+ if (seen.has(uiId)) {
162
+ return new Set([uiId]) // Cycle guard.
163
+ }
164
+
165
+ seen.add(uiId)
166
+
167
+ const chain = new Set<string>([uiId])
168
+ const rec = recordByUiId.get(uiId)
169
+ const parent = rec?.get('parent_ui_id')?.ifString()?.getValue()
170
+ if (parent) {
171
+ for (const ancestor of computeChain(parent, seen)) {
172
+ chain.add(ancestor)
173
+ }
174
+ }
175
+
176
+ chainByUiId.set(uiId, chain)
177
+ return chain
178
+ }
179
+
180
+ for (const inst of allInstances) {
181
+ const ui = inst.get('ui_id')?.ifString()?.getValue()
182
+ if (ui) {
183
+ computeChain(ui, new Set())
184
+ }
185
+ }
186
+
187
+ return chainByUiId
188
+ }
189
+
190
+ /**
191
+ * Detect whether any action/subflow nested inside a DoInParallel branch or TryCatch arm has its output
192
+ * referenced from outside that branch/arm. Returns true if hoisting is detected (caller should fall back
193
+ * to Record() API), false otherwise.
194
+ */
195
+ export function detectHoistedOutputBindings(allInstances: ReadonlyArray<Record>): boolean {
196
+ if (allInstances.length === 0) {
197
+ return false
198
+ }
199
+
200
+ const tryCatchLogicId = FLOW_LOGIC_SYS_ID_MAP[FLOW_LOGIC.TRY_CATCH]
201
+
202
+ // Collect ui_ids of all DoInParallel branches and TryCatch arms.
203
+ const branchUiIds = new Set<string>()
204
+ for (const inst of allInstances) {
205
+ if (inst.getTable() !== 'sys_hub_flow_logic_instance_v2') {
206
+ continue
207
+ }
208
+ const logicDef = inst.get('logic_definition')?.ifString()?.getValue()
209
+ if (
210
+ logicDef === DO_IN_PARALLEL_BLOCK_SYS_ID ||
211
+ logicDef === TRY_CATCH_CATCH_SYS_ID ||
212
+ (tryCatchLogicId && logicDef === tryCatchLogicId)
213
+ ) {
214
+ const uiId = inst.get('ui_id')?.ifString()?.getValue()
215
+ if (uiId) {
216
+ branchUiIds.add(uiId)
217
+ }
218
+ }
219
+ }
220
+
221
+ if (branchUiIds.size === 0) {
222
+ return false
223
+ }
224
+
225
+ // Build ancestor chain map and reference index.
226
+ const ancestorChainMap = buildAncestorChainMap(allInstances)
227
+ const referenceIndex = buildReferenceIndex(allInstances)
228
+
229
+ // Map: action/subflow UUID → the branch/arm ui_id that contains it (directly or transitively).
230
+ // Uses the full ancestor chain so actions nested inside If/ForEach within a branch are also caught.
231
+ const innerActionBranch = new Map<string, string>()
232
+ for (const [uuid, record] of uuidToRecordMap.entries()) {
233
+ const uiId = record.get('ui_id')?.ifString()?.getValue()
234
+ if (!uiId) {
235
+ continue
236
+ }
237
+ const chain = ancestorChainMap.get(uiId)
238
+ if (!chain) {
239
+ continue
240
+ }
241
+ for (const branchUiId of branchUiIds) {
242
+ if (chain.has(branchUiId)) {
243
+ innerActionBranch.set(uuid, branchUiId)
244
+ break
245
+ }
246
+ }
247
+ }
248
+
249
+ if (innerActionBranch.size === 0) {
250
+ return false
251
+ }
252
+
253
+ // For each inner action UUID, check if any source record referencing it lives outside its branch.
254
+ for (const [uuid, branchUiId] of innerActionBranch.entries()) {
255
+ const sources = referenceIndex.get(uuid)
256
+ if (!sources) {
257
+ continue
258
+ }
259
+
260
+ for (const sourceUiId of sources) {
261
+ if (!sourceUiId) {
262
+ continue
263
+ }
264
+ const ancestorChain = ancestorChainMap.get(sourceUiId)
265
+ const isInside = ancestorChain ? ancestorChain.has(branchUiId) : false
266
+ if (!isInside) {
267
+ return true
268
+ }
269
+ }
270
+ }
271
+
272
+ return false
273
+ }
274
+
275
+ function buildReferenceIndex(allInstances: ReadonlyArray<Record>): Map<string, Set<string | undefined>> {
276
+ const result = new Map<string, Set<string | undefined>>()
277
+ const uuidRe = /\{\{(?:step\[)?([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/g
278
+ for (const inst of allInstances) {
279
+ const valuesStr = readValuesForPillScan(inst)
280
+ if (!valuesStr) {
281
+ continue
282
+ }
283
+
284
+ const sourceUiId = inst.get('ui_id')?.ifString()?.getValue()
285
+ for (const m of valuesStr.matchAll(uuidRe)) {
286
+ const uuid = m[1]
287
+ if (!uuid) {
288
+ continue
289
+ }
290
+
291
+ let sources = result.get(uuid)
292
+ if (!sources) {
293
+ sources = new Set<string | undefined>()
294
+ result.set(uuid, sources)
295
+ }
296
+
297
+ sources.add(sourceUiId)
298
+ }
299
+ }
300
+ return result
301
+ }
302
+
303
+ /**
304
+ * Read an instance record's `values` column for substring-search of datapill UUIDs. ServiceNow stores the values field
305
+ * as a gzipped+base64 string. Returns the decompressed JSON-stringified payload, or `undefined` if the column is
306
+ * missing / unreadable. Errors are swallowed because pill-scan failure is non-critical — the worst case is
307
+ * over-keeping a binding.
308
+ */
309
+ // Memoizes the decoded `values` column per record. Decoding gzips (via `gunzipSync`) which is expensive, and the same
310
+ // records are scanned repeatedly — once by `buildReferenceIndex`, once per catch arm in `buildCatchParamMap`, etc.
311
+ // Keyed by Record identity (WeakMap), so entries are collected once a flow's records go out of scope.
312
+ const valuesScanCache = new WeakMap<Record, string | undefined>()
313
+
314
+ function readValuesForPillScan(record: Record): string | undefined {
315
+ const cached = valuesScanCache.get(record)
316
+ if (cached !== undefined || valuesScanCache.has(record)) {
317
+ return cached
318
+ }
319
+
320
+ const decoded = decodeValuesForPillScan(record)
321
+ valuesScanCache.set(record, decoded)
322
+
323
+ return decoded
324
+ }
325
+
326
+ function decodeValuesForPillScan(record: Record): string | undefined {
327
+ const raw = record.get('values')?.getValue()
328
+ if (raw === undefined || raw === null) {
329
+ return undefined
330
+ }
331
+
332
+ if (typeof raw !== 'string') {
333
+ // Already deserialized — stringify so callers can do substring search.
334
+ try {
335
+ return JSON.stringify(raw)
336
+ } catch {
337
+ return undefined
338
+ }
339
+ }
340
+
341
+ if (raw === '') {
342
+ return ''
343
+ }
344
+
345
+ // gzip magic bytes are `H4sI` when base64-encoded. `gunzipSync` (fflate) takes/returns a Uint8Array, so decode the
346
+ // base64 to bytes on the way in and back to utf8 on the way out.
347
+ if (raw.startsWith('H4sI')) {
348
+ try {
349
+ return Buffer.from(gunzipSync(Buffer.from(raw, 'base64'))).toString('utf8')
350
+ } catch {
351
+ return undefined
352
+ }
353
+ }
354
+
355
+ return raw
356
+ }
357
+
122
358
  /**
123
359
  * Extract CallExpression and VariableStatement from an instance shape
124
360
  * Handles both VariableStatementShape (for Action/Subflow) and bare CallExpressionShape (for flow logic)
@@ -126,7 +362,7 @@ export function buildUuidToIdentifierMap(recordToShapeMap: Map<Record, Shape>):
126
362
  * @param instanceShape - The shape to extract from
127
363
  * @returns Object containing callExpression and variableStatement
128
364
  */
129
- export function extractInstanceShapeInfo(instanceShape: Shape): FlowInstanceShapeInfo {
365
+ function extractInstanceShapeInfo(instanceShape: Shape): FlowInstanceShapeInfo {
130
366
  let callExpression: CallExpressionShape
131
367
  let variableStatement: VariableStatementShape | CallExpressionShape
132
368
 
@@ -141,32 +377,6 @@ export function extractInstanceShapeInfo(instanceShape: Shape): FlowInstanceShap
141
377
  return { callExpression, variableStatement }
142
378
  }
143
379
 
144
- /**
145
- * Rebuild a CallExpressionShape with transformed properties
146
- * Creates a new CallExpression with the transformed props and cleaned config
147
- *
148
- * @param originalCallExpression - The original CallExpression
149
- * @param transformedProps - The transformed properties object
150
- * @param configShape - The config shape (possibly cleaned)
151
- * @param record - The source record for shape creation
152
- * @returns New CallExpressionShape with transformed properties
153
- */
154
- export function rebuildCallExpressionWithDatapills(
155
- originalCallExpression: CallExpressionShape,
156
- transformedProps: { [key: string]: Shape },
157
- configShape: Shape,
158
- record: Record
159
- ): CallExpressionShape {
160
- const originalActionDef = originalCallExpression.getArgument(0, false)
161
- const newPropsArg = Shape.from(record, transformedProps)
162
-
163
- return new CallExpressionShape({
164
- source: originalCallExpression.getSource(),
165
- callee: originalCallExpression.getCallee(),
166
- args: [originalActionDef, configShape, newPropsArg],
167
- })
168
- }
169
-
170
380
  /**
171
381
  * Rebuild an instance shape (VariableStatement or CallExpression) with transformed datapills
172
382
  *
@@ -175,7 +385,7 @@ export function rebuildCallExpressionWithDatapills(
175
385
  * @param instanceShapeInfo - Information extracted from the instance shape
176
386
  * @returns Rebuilt VariableStatementShape or the new CallExpression
177
387
  */
178
- export function rebuildInstanceWithDatapills(
388
+ function rebuildInstanceWithDatapills(
179
389
  instanceShape: Shape,
180
390
  newCallExpression: CallExpressionShape,
181
391
  instanceShapeInfo: FlowInstanceShapeInfo
@@ -203,7 +413,7 @@ export function rebuildInstanceWithDatapills(
203
413
  * @param callee - The callee string (e.g., 'wfa.flowLogic.if', 'wfa.action')
204
414
  * @returns true if the callee is a flow logic call
205
415
  */
206
- export function isFlowLogicCallee(callee: string): boolean {
416
+ function isFlowLogicCallee(callee: string): boolean {
207
417
  return callee.startsWith(FLOW_LOGIC_PREFIX)
208
418
  }
209
419
 
@@ -212,7 +422,7 @@ export function isFlowLogicCallee(callee: string): boolean {
212
422
  * @param callee - The flow logic callee (e.g., 'wfa.flowLogic.if')
213
423
  * @returns true if the flow logic has a body
214
424
  */
215
- export function flowLogicHasBody(callee: string): boolean {
425
+ function flowLogicHasBody(callee: string): boolean {
216
426
  // Flow logic with bodies: if, elseIf, else, forEach, tryCatch, doInParallel
217
427
  // Flow logic without bodies: goBackTo, waitForADuration, exitLoop, endFlow, skipIteration
218
428
  return [
@@ -237,7 +447,7 @@ export function flowLogicHasBody(callee: string): boolean {
237
447
  * @param callee - The flow logic callee
238
448
  * @returns Array of arrow function shapes
239
449
  */
240
- export function extractFlowLogicBodies(callExpression: CallExpressionShape, callee: string): ArrowFunctionShape[] {
450
+ function extractFlowLogicBodies(callExpression: CallExpressionShape, callee: string): ArrowFunctionShape[] {
241
451
  const bodies: ArrowFunctionShape[] = []
242
452
 
243
453
  if (callee === FLOW_LOGIC.TRY_CATCH) {
@@ -288,7 +498,7 @@ export function extractFlowLogicBodies(callExpression: CallExpressionShape, call
288
498
  * @param record - The source record
289
499
  * @returns Rebuilt CallExpressionShape
290
500
  */
291
- export function rebuildFlowLogicWithDatapills(
501
+ function rebuildFlowLogicWithDatapills(
292
502
  callExpression: CallExpressionShape,
293
503
  transformedConfig: ObjectShape,
294
504
  transformedBodies: ArrowFunctionShape[],
@@ -544,12 +754,14 @@ export function processInstanceForDatapills(
544
754
  }
545
755
  }
546
756
 
547
- // Rebuild body if children changed
757
+ // Rebuild body if children changed. Preserve the arrow's `returnValue` if present.
758
+ const returnValue = body.getReturnValue()
548
759
  const transformedBody = anyChildChanged
549
760
  ? new ArrowFunctionShape({
550
761
  source: body.getSource(),
551
762
  parameters: body.getParameters(),
552
763
  statements: transformedChildren,
764
+ ...(returnValue !== undefined && { returnValue }),
553
765
  })
554
766
  : body
555
767
 
@@ -194,6 +194,16 @@ export async function buildVariableRecords(options: {
194
194
  additionalProperties['reference'] = referenceTable
195
195
  }
196
196
  }
197
+
198
+ // Handle field_list and template_value columns - extract dependent field.
199
+ // Always write all three fields so that removing 'dependent' from fluent
200
+ // explicitly clears the old value in ServiceNow rather than leaving it stale.
201
+ if (internal_type === 'field_list' || internal_type === 'template_value') {
202
+ const dependent = config.get('dependent')?.ifString()?.getValue() ?? ''
203
+ additionalProperties['dependent'] = dependent
204
+ additionalProperties['dependent_on_field'] = dependent
205
+ additionalProperties['use_dependent_field'] = dependent !== ''
206
+ }
197
207
  additionalProperties['internal_type'] = internal_type
198
208
  additionalProperties['max_length'] =
199
209
  config.get('maxLength')?.ifNumber()?.getValue() ?? getDefaultMaxLength(internal_type)
@@ -400,7 +410,11 @@ function buildBasicVariableShape(
400
410
  // Add dependent field for template_value and field_list column types
401
411
  if (fieldType === 'template_value' || fieldType === 'field_list') {
402
412
  const dependent = variable.get('dependent')?.ifString()?.getValue()
403
- additionalProps['dependent'] = dependent ?? ''
413
+ // For field_list, dependent is optional — only emit it when non-empty.
414
+ // For template_value, dependent is required by its type, so always include it.
415
+ if (fieldType === 'template_value' || dependent) {
416
+ additionalProps['dependent'] = dependent ?? ''
417
+ }
404
418
  }
405
419
 
406
420
  return new CallExpressionShape({
@@ -521,7 +535,7 @@ function validateAndPopulateChoiceProperties(
521
535
  }
522
536
 
523
537
  // Default to dropdown_with_none
524
- let choiceValue = ChoiceDropdown.DROPDOWN_WITHOUT_NONE
538
+ let choiceValue = ChoiceDropdown.DROPDOWN_WITH_NONE
525
539
 
526
540
  // Handle dropdown type configuration
527
541
  const defaultValue = config.get('default')
@@ -531,23 +545,23 @@ function validateAndPopulateChoiceProperties(
531
545
  const choiceTypeValue = choiceTypeNode.getValue() as choiceDropdownType
532
546
  const choiceDropdownValue = choiceDropdown.indexOf(choiceTypeValue)
533
547
 
534
- if (choiceDropdownValue === ChoiceDropdown.DROPDOWN_WITHOUT_NONE) {
535
- // Validate default value exists for dropdown_without_none
536
- if (defaultValue.isUndefined()) {
537
- diagnostics.error(config, 'Default value is required for dropdown type "dropdown_without_none"')
538
- return
539
- }
540
- choiceValue = ChoiceDropdown.DROPDOWN_WITHOUT_NONE
541
- } else if (choiceDropdownValue !== ChoiceDropdown.DROPDOWN_WITH_NONE) {
542
- // Only dropdown_with_none and dropdown_without_none are valid
548
+ if (choiceDropdownValue === -1) {
549
+ // Not a recognized dropdown type
543
550
  diagnostics.error(
544
551
  choiceTypeNode,
545
- 'Invalid dropdown type, expected "dropdown_with_none" or "dropdown_without_none"'
552
+ `Invalid dropdown type "${choiceTypeValue}", expected one of: ${choiceDropdown.join(', ')}`
546
553
  )
547
554
  return
548
- } else {
549
- choiceValue = choiceDropdownValue
550
555
  }
556
+
557
+ // dropdown_without_none has no "--None--" entry, so a default value is required
558
+ if (choiceDropdownValue === ChoiceDropdown.DROPDOWN_WITHOUT_NONE && defaultValue.isUndefined()) {
559
+ diagnostics.error(config, 'Default value is required for dropdown type "dropdown_without_none"')
560
+ return
561
+ }
562
+
563
+ // Preserve whatever valid dropdown type came through (none/with_none/suggestion/without_none)
564
+ choiceValue = choiceDropdownValue
551
565
  }
552
566
 
553
567
  // Set the choice value