import { CallExpressionShape, Plugin, isSNScope, type Record as SdkRecord, Shape } from '@servicenow/sdk-build-core' import { NowIdShape } from './now-id-plugin' // Define table names as constants const SYS_DATA_POLICY = 'sys_data_policy2' const SYS_DATA_POLICY_RULE = 'sys_data_policy_rule' /** * Type for data policy rule configuration (internal plugin representation) */ type DataPolicyRuleInternal = { $id?: NowIdShape mandatory?: boolean | 'ignore' readOnly?: boolean | 'ignore' table?: string } /** * Most-restrictive-wins merge for a single boolean|'ignore' property. * Priority: true > false > 'ignore' / undefined */ const mergeRestrictive = ( existing: boolean | 'ignore' | undefined, incoming: boolean | 'ignore' | undefined ): boolean | 'ignore' | undefined => { if (incoming === true || existing === true) { return true } if (incoming === false || existing === false) { return false } return incoming ?? existing } /** * Converts ServiceNow string format ('true'/'false'/'ignore') to boolean | 'ignore' * Used when reading from database (toShape) */ const stringToBoolean = (value?: string): boolean | 'ignore' | undefined => { switch (value) { case 'true': return true case 'false': return false case 'ignore': return 'ignore' default: return undefined } } /** * Converts boolean | 'ignore' to ServiceNow string format ('true'/'false'/'ignore') * Used when writing to database (toRecord) * Default: 'ignore' - matches ServiceNow platform default for null/undefined values */ const booleanToString = (value: Shape): string => { if (value.isBoolean()) { return value.getValue() ? 'true' : 'false' } return 'ignore' } /** * Checks if a value is a valid rule property (boolean or 'ignore') * Used for validation in toRecord */ const isValidRuleValue = (value: Shape): boolean => { return value.isBoolean() || (value.isString() && value.getValue() === 'ignore') } export const DataPolicyPlugin = Plugin.create({ name: 'DataPolicyPlugin', records: { [SYS_DATA_POLICY]: { relationships: { [SYS_DATA_POLICY_RULE]: { via: 'sys_data_policy', descendant: true, }, }, toShape(record, { descendants, logger }) { const rulesRecord: Record = {} const parentTable = record.get('model_table')?.ifString()?.getValue() descendants.query(SYS_DATA_POLICY_RULE).forEach((rule) => { const fieldNameValue = rule.get('field')?.ifString()?.getValue() if (!fieldNameValue) { return // Skip rules without a field name } // Convert ServiceNow string format to boolean | 'ignore' const mandatoryValue = rule.get('mandatory')?.ifString()?.getValue() const readOnlyValue = rule.get('disabled')?.ifString()?.getValue() const mandatory = stringToBoolean(mandatoryValue) const readOnly = stringToBoolean(readOnlyValue) if (rulesRecord[fieldNameValue]) { // Multiple rule records for the same field — most restrictive wins. // Priority: true > false > 'ignore' / undefined // WARNING: Only the first rule's $id is kept in Fluent. The duplicate rule // record (this one) will NOT be deleted from the instance on next deploy. // Clean up orphan rule records manually on the instance. logger.warn( `Data Policy '${record.getId().getValue()}': field '${fieldNameValue}' has multiple rule records on the instance. ` + `Merging values using most-restrictive-wins. The duplicate rule '${rule.getId().getValue()}' will NOT be deleted from the instance — remove it manually.` ) const existing = rulesRecord[fieldNameValue] const mergedMandatory = mergeRestrictive(existing.mandatory, mandatory) const mergedReadOnly = mergeRestrictive(existing.readOnly, readOnly) // Handle mutual exclusivity with priority rules: // 1. mandatory=true beats readOnly=true → set mandatory=true, readOnly=false // 2. readOnly=true beats mandatory=false → set readOnly=true, remove mandatory // 3. mandatory=false beats readOnly=false → set mandatory=false, remove readOnly let finalMandatory = mergedMandatory let finalReadOnly = mergedReadOnly if (mergedMandatory === true && mergedReadOnly === true) { // Case 1: mandatory=true wins over readOnly=true finalReadOnly = false } else if (mergedReadOnly === true && mergedMandatory === false) { // Case 2: readOnly=true wins over mandatory=false finalMandatory = 'ignore' } else if (mergedMandatory === false && mergedReadOnly === false) { // Case 3: mandatory=false wins over readOnly=false finalReadOnly = 'ignore' } if (finalMandatory !== undefined && finalMandatory !== 'ignore') { existing.mandatory = finalMandatory } else { delete existing.mandatory } if (finalReadOnly !== undefined && finalReadOnly !== 'ignore') { existing.readOnly = finalReadOnly } else { delete existing.readOnly } return } const ruleConfig: DataPolicyRuleInternal = { $id: NowIdShape.from(rule), } // Only include properties with actual boolean values (not 'ignore') // Omitting a property is equivalent to 'ignore' - cleaner generated code if (mandatory !== undefined && mandatory !== 'ignore') { ruleConfig.mandatory = mandatory } if (readOnly !== undefined && readOnly !== 'ignore') { ruleConfig.readOnly = readOnly } // Add table field if it's different from parent policy's table const table = rule.get('table')?.ifString()?.getValue() if (table && table !== parentTable) { ruleConfig.table = table } rulesRecord[fieldNameValue] = ruleConfig }) return { success: true, value: new CallExpressionShape({ source: record, callee: 'DataPolicy', args: [ record.transform(({ $ }) => ({ $id: $.val(NowIdShape.from(record)), table: $.from('model_table').def(''), shortDescription: $.from('short_description').def(''), active: $.toBoolean().def(true), applyToImportSets: $.from('apply_import_set').toBoolean().def(true), applyToSOAP: $.from('apply_soap').toBoolean().def(true), conditions: $.def(''), description: $.def(''), useAsUiPolicyOnClient: $.from('enforce_ui').toBoolean().def(true), inherit: $.toBoolean().def(false), modelId: $.from('model_id').def(''), reverseIfFalse: $.from('reverse_if_false').toBoolean().def(true), rules: $.val(Object.keys(rulesRecord).length > 0 ? rulesRecord : undefined), })), ], }), } }, }, [SYS_DATA_POLICY_RULE]: {}, }, shapes: [ { shape: CallExpressionShape, fileTypes: ['fluent'], async toRecord(callExpression, { config, factory, diagnostics }) { if (callExpression.getCallee() !== 'DataPolicy') { return { success: false } } const arg = callExpression.getArgument(0).asObject() // Validate table scope prefix for scoped apps const tableValue = arg.get('table') if (tableValue?.isString() && config?.scope) { const tableName = tableValue.getValue() const scopeName = config.scope //Note: If scope is sn_ or global, this validation will not work as expected if (!tableName.startsWith(`${scopeName}_`) && !isSNScope(scopeName) && scopeName !== 'global') { diagnostics.error( arg.get('table'), `'table' property should start with scope prefix '${scopeName}_'. Data Policies in scoped apps can only be created for tables within the same scope.` ) } } // Create the Data Policy record const policyRecord = await factory.createRecord({ source: callExpression, table: SYS_DATA_POLICY, explicitId: arg.get('$id'), properties: arg.transform(({ $ }) => ({ model_table: $.from('table').def(''), short_description: $.from('shortDescription').def(''), active: $.from('active').def(true), apply_import_set: $.from('applyToImportSets').def(true), apply_soap: $.from('applyToSOAP').def(true), conditions: $.from('conditions').def(''), description: $.from('description').def(''), enforce_ui: $.from('useAsUiPolicyOnClient').def(true), inherit: $.from('inherit').def(false), model_id: $.from('modelId').def(''), reverse_if_false: $.from('reverseIfFalse').def(true), })), }) // Process rules if they exist (Record structure) const rules = arg.get('rules') const ruleRecords: SdkRecord[] = [] if (!rules?.isObject()) { return { success: true, value: policyRecord } } const ruleKeys = rules.keys() // Process each field name as a key in the rules record for (const fieldName of ruleKeys) { const ruleConfig = rules.get(fieldName) if (!ruleConfig?.isObject()) { continue } // Get rule property values let mandatoryValue = ruleConfig.get('mandatory') let readOnlyValue = ruleConfig.get('readOnly') const hasMandatoryProp = isValidRuleValue(mandatoryValue) const hasReadOnlyProp = isValidRuleValue(readOnlyValue) // If no valid properties, default both to 'ignore' instead of throwing error // This creates a rule record with both fields set to 'ignore' in the database if (!hasMandatoryProp && !hasReadOnlyProp) { mandatoryValue = Shape.from(ruleConfig, 'ignore') readOnlyValue = Shape.from(ruleConfig, 'ignore') } // Validate mutual exclusivity: mandatory and readOnly cannot both be true if ( mandatoryValue?.isBoolean() && mandatoryValue.getValue() === true && readOnlyValue?.isBoolean() && readOnlyValue.getValue() === true ) { diagnostics.error( ruleConfig, `Rule for field '${fieldName}': mandatory and readOnly cannot both be true - these are mutually exclusive` ) continue } // Validate mandatory field is not set for tables outside the current scope const ruleTableValue = ruleConfig.get('table') if ( mandatoryValue?.isBoolean() && mandatoryValue.getValue() === true && ruleTableValue?.isString() && config?.scope ) { const ruleTableName = ruleTableValue.getValue() const scopeName = config.scope const policyTableName = arg.get('table')?.ifString()?.getValue() ?? '' // Check if rule table is different from policy table and outside scope if ( ruleTableName !== policyTableName && !ruleTableName.startsWith(`${scopeName}_`) && !isSNScope(scopeName) && scopeName !== 'global' ) { diagnostics.error( ruleConfig.get('table') || ruleConfig, `Rule for field '${fieldName}': cannot set mandatory=true for table '${ruleTableName}' which is outside the current scope '${scopeName}'. For tables in a different scope than the data policy record, you cannot make a field mandatory.` ) continue } } const ruleRecord = await factory.createRecord({ source: ruleConfig, table: SYS_DATA_POLICY_RULE, explicitId: ruleConfig.get('$id'), properties: ruleConfig.transform(({ $ }) => ({ sys_data_policy: $.val(policyRecord.getId()), table: $.from('table').def(arg.get('table')?.ifString()?.getValue() ?? ''), field: $.val(fieldName), mandatory: $.val(booleanToString(mandatoryValue)), disabled: $.val(booleanToString(readOnlyValue)), })), }) ruleRecords.push(ruleRecord) } return { success: true, value: policyRecord.with(...ruleRecords) } }, }, ], })