import { CallExpressionShape, Database, IdentifierShape, isGUID, Plugin, type Shape, type StringShape, unloadBuilder, type Record, type Plugins, } from '@servicenow/sdk-build-core' import { NowIdShape } from './now-id-plugin' import { Acl, ApplicationMenu, BusinessRule, ChoiceSet, CrossScopePrivilege, DataLookup, Property, Role, Test, TestSuite, UserPreference, ImportSet, Sla, } from '@servicenow/sdk-core/runtime/app' import { GraphQLApi } from '@servicenow/sdk-core/runtime/graphql' import { RetryPolicy, AliasTemplate, Alias } from '@servicenow/sdk-core/runtime/alias' import { ServicePortal, SPWidget, SPHeaderFooter, SPAngularProvider, SPWidgetDependency, CssInclude, JsInclude, SPPage, SPTheme, SPMenu, SPPageRouteMap, } from '@servicenow/sdk-core/runtime/service-portal' import { CatalogItem, CatalogClientScript, VariableSet, CatalogUiPolicy, CatalogItemRecordProducer, } from '@servicenow/sdk-core/runtime/service-catalog' import { ClientScript } from '@servicenow/sdk-core/runtime/clientscript' import { ScriptAction, ScriptInclude, ScheduledScript } from '@servicenow/sdk-core/runtime/sys' import { List, Form } from '@servicenow/sdk-core/runtime/ui' import { Table } from '@servicenow/sdk-core/runtime/db' import { RestApi, RestMessage } from '@servicenow/sdk-core/runtime/rest' import { EmailNotification, InboundEmailAction } from '@servicenow/sdk-core/runtime/notification' import { UiAction, UiPage, UiPolicy, DataPolicy } from '@servicenow/sdk-core/runtime/ui' import { Applicability, UxListMenuConfig, Workspace } from '@servicenow/sdk-core/runtime/uxf' import { Dashboard } from '@servicenow/sdk-core/runtime/dashboard' import { StateModel } from '@servicenow/sdk-core/runtime/state-model' import { ColumnTypeCheck, LinterCheck, ScriptOnlyCheck, TableCheck } from '@servicenow/sdk-core/runtime/instancescan' import { DEFAULT_VIEW } from './common/constants' /** * Metadata tables whose records commonly carry scripts that NowUnit extracts and tests. * For every other table `sys_name` is dropped from `data` (it is platform-managed and surfaced * only as the generated export/variable name). */ export const CommonNowUnitScriptTables = new Set([ 'sys_script_fix', // Fix Script 'sys_processor', // Processor 'sys_ux_client_script', // UX (Next Experience) Client Script 'sys_ux_client_script_include', // UX Client Script Include 'sys_ux_data_broker_transform', // UX Data Broker Transform 'sys_ux_data_broker_scriptlet', // UX Data Broker Scriptlet 'sys_relationship', // Relationship (query/apply scripts) ]) const VIEW_TABLE = 'sys_ui_view' // Referenced tables for which the platform's generic reference fixup - replacing a minted sys_id // with the coalesce key value stored in the field's XML attribute - is confirmed to work on both // create and update. Only add a table here once update-time behavior has actually been verified; // see TABLES_WITH_CUSTOM_REFERENCE_LOADER for the opt-out that applies regardless of this set. const VERIFIED_REFERENCE_FIXUP_TABLES = new Set([VIEW_TABLE, 'sys_user_role']) // Source tables whose own record loader (e.g. the dictionary loader for sys_dictionary) does not // honor the coalesce key XML attribute during update, even for a referenced table listed in // VERIFIED_REFERENCE_FIXUP_TABLES. Reference fields on these tables are always passed through // as-is instead of being reference-fixed up. const TABLES_WITH_CUSTOM_REFERENCE_LOADER = new Set(['sys_dictionary']) /** * Returns the referenced record's coalesce key/value pairs, or undefined when the column isn't * a reference that can be addressed by its coalesce keys. * * The platform exports reference fields with the target's coalesce keys as XML attributes * (`9b6d...`), which the parser preserves on the field shape. * Looking up `referenceTable`'s coalesce strategy tells us which of those attributes to read, so * a reference can be rendered in the generated Fluent as e.g. `view: 'customview'` instead of an * opaque sys_id. * * Columns the parser already resolved arrive as a `RecordId` and are left alone, with one * exception: the platform's built-in default view is exported as `Default * view`, which the parser resolves to a `RecordId` whose only coalesce key is `name: * 'NULL'`. Left alone, that would generate Fluent code with the literal value `'NULL'` for the * view field, which wouldn't build correctly on a subsequent build. That key is extracted here so * the caller can detect this case and emit the `default_view` identifier instead. */ function extractCoalesceValue(shape: StringShape | undefined, referenceTable: string, plugins: Plugins) { if (!shape || shape.isRecordId() || shape.isEmpty()) { if (referenceTable === VIEW_TABLE && shape?.ifRecordId()?.hasPrimaryKey()) { const recordId = shape.asRecordId() return Object.keys(recordId.getKeys()!).map((key): [string, string] => [key, recordId.getPrimaryKey()]) } return undefined } const coalesceStrategy = plugins.getCoalesceStrategy(referenceTable) if (!coalesceStrategy || !Array.isArray(coalesceStrategy)) { return undefined } const coalesceEntries: [key: string, value: string][] = [] for (const key of coalesceStrategy) { const value = shape.getXmlAttributes()[key] if (value === undefined) { return undefined } coalesceEntries.push([key, value]) } return coalesceEntries } export const RecordPlugin = Plugin.create({ name: 'RecordPlugin', records: { '*': { getUpdateName(record) { return { success: true, value: `${record.getTable()}_${record.getId().getValue()}`, } }, async diff(existing, incoming) { const changeDatabase = existing.compare(incoming) return { success: true, value: changeDatabase.hasChanges() ? new Database(changeDatabase.query()) : new Database(), } }, async toShape(record, { compiler, factory, plugins }) { const tableName = record.getTable() const columnTypes = compiler.getTableColumnTypes(tableName) const mandatoryColumns = compiler.getMandatoryColumns(tableName) const referenceColumns = compiler.getTableReferenceColumns(tableName) // createReference() is async and record.transform()'s callback is not, so resolve these first. const references = new Map() for (const [column, referenceTable] of referenceColumns ?? []) { const colValue = record.get(column)?.ifString() const coalesceEntries = extractCoalesceValue(colValue, referenceTable, plugins) const [coalesceEntry, ...extraEntries] = coalesceEntries ?? [] if ( !colValue || !coalesceEntry || extraEntries.length > 0 || !VERIFIED_REFERENCE_FIXUP_TABLES.has(referenceTable) || TABLES_WITH_CUSTOM_REFERENCE_LOADER.has(tableName) ) { continue } const [coalesceKey, coalesceKeyValue] = coalesceEntry references.set( column, referenceTable === VIEW_TABLE && colValue.asString().getValue() === DEFAULT_VIEW ? new IdentifierShape({ source: record, name: 'default_view' }) : await factory.createReference({ source: colValue, table: referenceTable, guid: colValue.getValue(), keys: { [coalesceKey]: coalesceKeyValue }, }) ) } // Keep sys_name in `data` for script-bearing metadata so it survives the round trip // (NowUnit names extracted scripts by sys_name); drop it for everything else. const preserveSysName = CommonNowUnitScriptTables.has(tableName) const dataProperties = record.transform(({ $ }) => Object.fromEntries( record .keys() .filter( (key) => (preserveSysName || key !== 'sys_name') && key !== 'sys_scope' && key !== 'sys_update_name' ) .map((key) => { const shape = record.get(key) if (references.has(key)) { return [key, $.val(references.get(key))] } if (columnTypes?.has(key) && shape?.isString()) { const expectedType = columnTypes.get(key) try { if (expectedType === 'boolean') { return [key, $.val(shape.toBoolean())] } else if (expectedType === 'number') { return [key, $.val(shape.toNumber())] } else if (expectedType === 'array') { return [key, $.val(shape.asString().split(',')).def([''])] } else if (expectedType === 'array-optional') { const arrayParts = shape.asString().includes(',') return [key, arrayParts ? $.val(shape.asString().split(',')).def(['']) : $] } } catch { // Keep as string if conversion fails } } // Mandatory string fields must always be written to avoid build errors // from missing required properties in Data. return [key, shape?.isString() ? (mandatoryColumns?.has(key) ? $ : $.def('')) : $] }) ) ) const value = new CallExpressionShape({ source: record, callee: 'Record', exportName: record.get('sys_name')?.ifDefined()?.asString().getValue(), args: [ { $id: NowIdShape.from(record), table: tableName, data: dataProperties, }, ], }) return { success: true, value, } }, async toFile(record, { database }) { const recordBuilder = unloadBuilder(record.getTable()) const builder = recordBuilder.record(record) record .entries() .sort(([a], [b]) => a.localeCompare(b)) // Sort keys to make outputs more deterministic .forEach(([prop, shape]) => builder.field(prop, shape)) const updateName = record.get('sys_update_name').asString().getValue() const claims = database .query('sys_claim') .filter((claim) => claim.get('metadata_update_name').equals(updateName)) for (const claim of claims) { const claimBuilder = recordBuilder.record(claim) claim .entries() .sort(([a], [b]) => a.localeCompare(b)) // Sort keys to make outputs more deterministic .forEach(([prop, shape]) => claimBuilder.field(prop, shape)) } // sys_es_latest_script can attach to any script-bearing record. It's written into the // same file as its parent here, same as sys_claim above const esLatestSibling = database .query('sys_es_latest_script') .find((sibling) => sibling.get('id').equals(record.getId().getValue())) if (esLatestSibling) { const esLatestBuilder = recordBuilder.record(esLatestSibling) esLatestSibling .entries() .sort(([a], [b]) => a.localeCompare(b)) .forEach(([prop, shape]) => esLatestBuilder.field(prop, shape)) } return { success: true, value: { source: record, name: `${updateName}.xml`, category: record.getInstallCategory(), content: recordBuilder.end(), }, } }, }, }, shapes: [ { shape: CallExpressionShape, fileTypes: ['fluent'], async toRecord(callExpression, { factory, diagnostics, plugins, compiler }) { if (callExpression.getCallee() !== 'Record') { return { success: false } } const record = callExpression.getArgument(0).asObject() const table = record.get('table').asString().getValue() const refColumns = plugins.getReferenceColumns(table) const schemaRefColumns = compiler.getTableReferenceColumns(table) const tableOwningPlugin = TableOwnership[table as keyof typeof TableOwnership] if (tableOwningPlugin) { diagnostics.hint( callExpression, `For a better experience, consider using the ${tableOwningPlugin} API` ) } const dataObj = record.get('data').asObject() const properties: { [key: string]: unknown } = {} for (const k of dataObj.keys()) { const value = dataObj.get(k) // Prefer the plugin's declaration; fall back to what the table's schema reveals. const refTable = refColumns[k] ?? schemaRefColumns?.get(k) const refValue = refTable ? value?.ifStringLiteral()?.getValue() : undefined if ( refValue !== undefined && refTable && VERIFIED_REFERENCE_FIXUP_TABLES.has(refTable) && !TABLES_WITH_CUSTOM_REFERENCE_LOADER.has(table) ) { const strategy = plugins.getCoalesceStrategy(refTable) const isDefaultView = refTable === VIEW_TABLE && refValue === DEFAULT_VIEW // Only a lone coalesce key can be expressed as the field's value. const [coalesceKey] = Array.isArray(strategy) && strategy.length === 1 ? strategy : [] if (coalesceKey) { properties[k] = isGUID(refValue) ? refValue : await factory.createReference({ source: value, table: refTable, keys: { [coalesceKey]: isDefaultView ? 'NULL' : refValue }, ...(isDefaultView ? { guid: refValue } : {}), }) continue } } properties[k] = value } return { success: true, value: await factory.createRecord({ source: callExpression, table, explicitId: record.get('$id'), properties, }), } }, }, ], files: [ { matcher: /\.xml$/, async toRecord(file, { parser, logger }) { try { const records = await parser.parsePayload(file) const recordMap = new Map() for (const record of records) { const key = `${record.getTable()}::${record.getId().getValue()}` const existing = recordMap.get(key) const merged = existing ? existing.merge(record.properties()) // merge properties only to retain the action : record recordMap.set(key, merged) } const mergedRecords = Array.from(recordMap.values()) const [mergedFirst, ...mergedRest] = mergedRecords if (!mergedFirst) { return { success: false } } return { success: true, value: mergedFirst.with(...mergedRest) } } catch (e) { logger.debug(e) return { success: false } } }, }, ], }) export const TableOwnership = { sys_choice_set: ChoiceSet.name, sys_alias: Alias.name, sys_alias_templates: AliasTemplate.name, contract_sla: Sla.name, sys_retry_policy: RetryPolicy.name, sys_security_acl: Acl.name, sys_security_acl_role: Acl.name, sys_app_application: ApplicationMenu.name, sys_script: BusinessRule.name, sys_script_client: ClientScript.name, sys_scope_privilege: CrossScopePrivilege.name, sys_ui_form: Form.name, sys_ui_section: Form.name, sys_ui_form_section: Form.name, sys_ui_element: Form.name, sys_ui_list_element: List.name, sys_ui_list: List.name, sys_properties: Property.name, sys_user_role: Role.name, sys_user_role_contains: Role.name, sys_script_include: ScriptInclude.name, sp_widget: SPWidget.name, sp_header_footer: SPHeaderFooter.name, m2m_sp_widget_dependency: SPWidget.name, m2m_sp_ng_pro_sp_widget: SPWidget.name, sp_portal: ServicePortal.name, sp_ng_template: SPWidget.name, sp_dependency: SPWidgetDependency.name, sp_container: SPPage.name, sp_page: SPPage.name, sp_row: SPPage.name, sp_column: SPPage.name, sp_instance: SPPage.name, m2m_sp_dependency_css_include: SPWidgetDependency.name, m2m_sp_dependency_js_include: SPWidgetDependency.name, sp_css_include: CssInclude.name, sp_js_include: JsInclude.name, sp_angular_provider: SPAngularProvider.name, m2m_sp_ng_pro_sp_ng_pro: SPAngularProvider.name, sp_instance_menu: SPMenu.name, sp_rectangle_menu_item: SPMenu.name, sp_page_route_map: SPPageRouteMap.name, sys_atf_test: Test.name, sys_atf_test_suite: TestSuite.name, sys_ws_header_map: RestApi.name, sys_ws_query_parameter_map: RestApi.name, sys_ws_definition: RestApi.name, sys_ws_operation: RestApi.name, sys_ws_version: RestApi.name, sys_ws_header: RestApi.name, sys_ws_query_parameter: RestApi.name, sys_graphql_schema: GraphQLApi.name, sys_graphql_resolver: GraphQLApi.name, sys_graphql_resolver_mapping: GraphQLApi.name, sys_graphql_typeresolver: GraphQLApi.name, sys_rest_message: RestMessage.name, sys_rest_message_headers: RestMessage.name, sys_rest_message_fn: RestMessage.name, sys_rest_message_fn_headers: RestMessage.name, sys_rest_message_fn_parameters: RestMessage.name, sys_rest_message_fn_param_defs: RestMessage.name, sys_user_preference: UserPreference.name, sys_ui_page: UiPage.name, sys_ui_action: UiAction.name, sys_ui_action_role: UiAction.name, sys_ui_action_view: UiAction.name, sys_ui_policy: UiPolicy.name, sys_ui_policy_action: UiPolicy.name, sys_ui_policy_rl_action: UiPolicy.name, sys_data_policy2: DataPolicy.name, sys_data_policy_rule: DataPolicy.name, sttrm_model: StateModel.name, chg_model: StateModel.name, prb_model: StateModel.name, prb_task_model: StateModel.name, sysevent_script_action: ScriptAction.name, sysauto_script: ScheduledScript.name, sysevent_email_action: EmailNotification.name, sysevent_in_email_action: InboundEmailAction.name, sys_db_object: Table.name, sys_dictionary: Table.name, sys_hub_flow: 'Flow', sys_hub_trigger_instance_v2: 'wfa.trigger', sys_hub_action_instance_v2: 'wfa.action', sys_hub_sub_flow_instance_v2: 'wfa.subflow', sys_hub_flow_logic_instance_v2: 'wfa.flowLogic', sys_flow_step_definition: 'ActionStepDefinition', sys_decision: 'DecisionTablePlugin', sys_hub_action_type_definition: 'Action', sys_hub_step_instance: 'wfa.actionStep', sp_theme: SPTheme.name, m2m_sp_theme_css_include: SPTheme.name, m2m_sp_theme_js_include: SPTheme.name, sys_transform_map: ImportSet.name, sys_transform_entry: ImportSet.name, sys_transform_script: ImportSet.name, sys_ux_applicability: Applicability.name, sys_aix_widget: 'AiuxPlugin', sys_aix_widget_instance: 'AiuxPlugin', sys_aix_entity_widget_mapping: 'AiuxPlugin', sys_aix_page: 'AiuxPlugin', sys_aix_page_route_map: 'AiuxPlugin', sys_aix_experience: 'AiuxPlugin', sys_aix_experience_properties: 'AiuxPlugin', sys_aix_experience_page_rel: 'AiuxPlugin', sys_aix_app_shell: 'AiuxPlugin', sys_aix_menu: 'AiuxPlugin', sys_aix_menu_item: 'AiuxPlugin', sys_aix_menu_item_category: 'AiuxPlugin', sys_aix_layout: 'AiuxPlugin', sys_aix_theme: 'AiuxPlugin', sys_aix_color_swatch: 'AiuxPlugin', sys_aix_m2m_experience_theme: 'AiuxPlugin', sys_aix_dashboard: 'AiuxPlugin', sys_aix_dashboard_item: 'AiuxPlugin', sys_aix_dashboard_personalization_item: 'AiuxPlugin', sys_aix_m2m_experience_dashboard: 'AiuxPlugin', sys_aix_container: 'AiuxPlugin', sys_aix_dependency: 'AiuxPlugin', sys_aix_dependency_bundle: 'AiuxPlugin', sys_aix_m2m_widget_dependency: 'AiuxPlugin', sys_aix_m2m_widget_dependency_bundle: 'AiuxPlugin', sys_aix_m2m_bundle_dependency: 'AiuxPlugin', sys_ux_list_menu_config: UxListMenuConfig.name, sys_ux_list_category: UxListMenuConfig.name, sys_ux_list: UxListMenuConfig.name, sys_ux_applicability_m2m_list: UxListMenuConfig.name, sys_ux_page_registry: Workspace.name, sys_ux_app_config: Workspace.name, par_dashboard: Dashboard.name, par_dashboard_canvas: Dashboard.name, par_dashboard_tab: Dashboard.name, par_dashboard_widget: Dashboard.name, par_dashboard_permission: Dashboard.name, par_dashboard_visibility: Dashboard.name, scan_column_type_check: ColumnTypeCheck.name, scan_linter_check: LinterCheck.name, scan_script_only_check: ScriptOnlyCheck.name, scan_table_check: TableCheck.name, item_option_new_set: VariableSet.name, sc_cat_item: CatalogItem.name, sc_cat_item_producer: CatalogItemRecordProducer.name, catalog_script_client: CatalogClientScript.name, catalog_ui_policy: CatalogUiPolicy.name, sn_aia_agent: 'AiAgentPlugin', sn_aia_agent_config: 'AiAgentPlugin', sn_aia_version: 'AiAgentPlugin', sn_aia_tool: 'AiAgentPlugin', sn_aia_agent_tool_m2m: 'AiAgentPlugin', sn_aia_ltm_category_mapping: 'AiAgentPlugin', sn_aia_trigger_configuration: 'AiAgentPlugin', sn_aia_trigger_agent_usecase_m2m: 'AiAgentPlugin', sn_aia_usecase: 'AiAgenticWorkflowPlugin', sn_aia_usecase_config_override: 'AiAgenticWorkflowPlugin', sn_aia_team: 'AiAgenticWorkflowPlugin', sn_aia_team_member: 'AiAgenticWorkflowPlugin', sn_nowassist_skill_config: 'NowAssistSkillPlugin', sys_one_extend_capability: 'NowAssistSkillPlugin', dl_definition: DataLookup.name, }