import { CallExpressionShape, Plugin, isGUID, type Database, type Diagnostics, type Factory, type ObjectShape, type Record, type RecordId, type Shape, type Transform, } from '@servicenow/sdk-build-core' import { NowIdShape } from './now-id-plugin' import { NowIncludeShape } from './now-include-plugin' import { reverseObject, showGuidFieldDiagnostic } from './utils' // Table name constants — not exported from core tables, so declared here. const STTRM_MODEL = 'sttrm_model' const STTRM_STATE = 'sttrm_state' const STTRM_TRANSITION = 'sttrm_state_transition' const STTRM_CONDITION = 'sttrm_transition_condition' const STTRM_CONDITION_FIELD = 'sttrm_transition_condition_field' const STTRM_STATE_ATTRIBUTE = 'sttrm_state_attribute' // User Criteria M2M tables — only meaningful when the model's advancedSecurity is enabled. const STTRM_MODEL_USER_CRITERIA_READ_MTOM = 'sttrm_model_user_criteria_read_mtom' const STTRM_MODEL_USER_CRITERIA_WRITE_MTOM = 'sttrm_model_user_criteria_write_mtom' const STTRM_MODEL_USER_CRITERIA_HIDE_MTOM = 'sttrm_model_user_criteria_hide_mtom' // Polymorphic subclass tables, selected by the target table. const CHG_MODEL = 'chg_model' const PRB_MODEL = 'prb_model' const PRB_TASK_MODEL = 'prb_task_model' // target table → model subclass table const MODEL_TABLE_BY_TARGET: { [target: string]: string } = { change_request: CHG_MODEL, problem: PRB_MODEL, problem_task: PRB_TASK_MODEL, } // model table → its class-specific "default model" boolean field const DEFAULT_FLAG_BY_MODEL_TABLE: { [modelTable: string]: string } = { [CHG_MODEL]: 'default_change_model', [PRB_MODEL]: 'default_prb_model', [PRB_TASK_MODEL]: 'default_prb_task_model', } // The platform default for condition_script (sttrm_transition_condition) — an empty IIFE template. // Matches the sys_dictionary default_value so omitted scripts round-trip cleanly and query-only // conditions are not surfaced as a conditionScript on transform. const DEFAULT_CONDITION_SCRIPT = '(function (current) {\n // This function should return a true/false value to determine if the condition has passed or not\n // Do NOT use: gs.addErrorMessage, gs.addInfoMessage because evaluation of transition conditions happens frequently and in multiple different channels so should not contain UI specific code e.g. REST, Flow.\n})(current);' // OOB condition-type templates (sttrm_condition_type) → their sys_ids. The developer sets the // condition's `conditionType` to one of these names (the platform "Requires" dropdown) and the // plugin resolves it to the reference. A custom sys_id may be passed directly for non-OOB types. const CONDITION_TYPE_BY_NAME: { [name: string]: string } = { 'Transition Condition': '325636985303101034d1ddeeff7b12ed', 'Transition Script': 'eb8676985303101034d1ddeeff7b1291', Authorized: 'f6ec3dd4736b10108ef62d2b04f6a72e', 'Mandatory Fields': '532c46339f901210ac2fe02e2b0a1cd2', 'Not On hold': '7a790272c303101035ae3f52c1d3aeae', 'Risk evaluation': 'b11e0a59ff222210ec80ffffffffffed', 'Task has been through Approval': 'da0596ef5323101034d1ddeeff7b12e6', 'Task is Approved': '7d160f945303101034d1ddeeff7b12cb', 'Task is Rejected': '2ac5b9aac30f101035ae3f52c1d3ae48', } const CONDITION_TYPE_NAME_BY_ID = reverseObject(CONDITION_TYPE_BY_NAME) // OOB per-state attribute templates (sttrm_attribute) → their sys_ids. A custom sys_id may be passed directly. const ATTRIBUTE_BY_NAME: { [name: string]: string } = { allowImplementation: '2ca2cbd5ff2031107f54ffffffffff6c', allowCiModification: '9fbfd18affa431107f54ffffffffff03', } const ATTRIBUTE_NAME_BY_ID = reverseObject(ATTRIBUTE_BY_NAME) const detectModelTable = (targetTable: string): string => MODEL_TABLE_BY_TARGET[targetTable] ?? STTRM_MODEL // Ascending numeric comparator for a named field on a Record, with a fallback default. const byNumericField = (field: string, fallback: number) => (a: Record, b: Record): number => (a.get(field).ifString()?.ifNotEmpty()?.toNumber().getValue() ?? fallback) - (b.get(field).ifString()?.ifNotEmpty()?.toNumber().getValue() ?? fallback) // Derive a stable, developer-friendly state key from a state's label (e.g. "On Hold" → "on_hold"). const stateKeyFromLabel = (label: string): string => label .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, '_') .replace(/^_+|_+$/g, '') const splitRoles = (value: Shape): string[] | undefined => { const raw = value.ifString()?.getValue() ?? '' const arr = raw ? raw.split(',').filter(Boolean) : [] return arr.length ? arr : undefined } // Array elements may be a plain role name string or a reference to a Role() call — resolve either // to the role name that gets written into the comma-separated DB field. const joinRoles = (value: Shape): string | undefined => { const arr = value .ifArray() ?.getElements() .map((el) => el.ifRecord()?.get('name').getValue() ?? el.ifString()?.getValue() ?? '') ?? [] const filtered = arr.filter(Boolean) return filtered.length ? filtered.join(',') : undefined } // Resolves a `templateApprovalUsers`/`templateApprovalGroups`-style array (raw sys_id strings or // Record<'sys_user'|'sys_user_group'> references) into the comma-separated sys_id list the // platform's "List" collection field stores. Unlike read_roles/write_roles (comma-separated ROLE // NAMES via joinRoles), these fields store raw sys_ids, so invalid strings are flagged via // showGuidFieldDiagnostic rather than passed through. const resolveGuidList = ( items: Shape[], fieldName: string, tableName: string, diagnostics: Diagnostics ): string | undefined => { const guids: string[] = [] items.forEach((item, index) => { const recordId = item.ifRecord()?.getId().getValue() if (recordId) { guids.push(recordId) return } const str = item.ifString()?.getValue() if (str === undefined) { return } if (isGUID(str)) { guids.push(str) } else { showGuidFieldDiagnostic(item, `${fieldName}[${index}]`, tableName, diagnostics) } }) return guids.length ? guids.join(',') : undefined } // Determine whether a condition_script value is a real, developer-authored script (vs the empty // IIFE template default). Robust to whitespace/line-ending differences (the platform stores the // default with `\r`): strip the `(function (current) { ... })(current);` wrapper and all comments — // if no executable code remains, it is just the template and should not surface as a conditionScript. const isMeaningfulScript = (value: Shape | undefined): boolean => { const raw = value?.ifString()?.getValue() if (!raw) { return false } const code = raw .replace(/\(function\s*\(\s*current\s*\)\s*\{/, '') .replace(/\}\s*\)\s*\(\s*current\s*\)\s*;?\s*$/, '') .replace(/\/\/[^\r\n]*/g, '') .replace(/\/\*[\s\S]*?\*\//g, '') .replace(/\s+/g, '') return code.length > 0 } /** * Builds the `conditions` array shape for one transition (XML → Fluent). */ async function buildConditionShapes( conditions: Record[], descendants: Database, transform: Transform ): Promise[]> { return Promise.all( conditions.map(async (cond) => { // A plain $.def(DEFAULT_CONDITION_SCRIPT) is not enough here: NowIncludeShape.fromRecord // unconditionally writes a .js file, so calling it for every condition (even ones with no // real script) would litter the project with boilerplate template files. isMeaningfulScript // guards that, and (unlike an exact-string .def() comparison) is robust to the \r the // platform stores in the default value. const scriptValue = cond.get('condition_script') const scriptInclude = isMeaningfulScript(scriptValue) ? await NowIncludeShape.fromRecord(cond, scriptValue, transform) : undefined // Field names referenced by this condition (sttrm_transition_condition_field rows). $id is // required on ConditionField — the platform allows more than one field row with the same // name on the same condition, so every entry always carries its own identity. const fieldShapes = descendants .query(STTRM_CONDITION_FIELD, { transition_condition: cond.getId() }) .filter((f: Record) => Boolean(f.get('name').ifString()?.getValue())) .map((f: Record) => f.transform(({ $ }) => ({ $id: $.val(NowIdShape.from(f)), name: $, })) ) return cond.transform(({ $ }) => ({ $id: $.val(NowIdShape.from(cond)), name: $, condition: $.def(''), conditionScript: scriptInclude ? $.val(scriptInclude) : $.def(undefined), // Map the condition_type reference back to its friendly name (or raw sys_id for custom types). conditionType: $.from('condition_type') .map((v) => { const id = v.ifString()?.getValue() if (!id) { return undefined } return CONDITION_TYPE_NAME_BY_ID[id] ?? id }) .def(undefined), active: $.toBoolean().def(true), // .toNumber() throws on an empty string (a bare in XML), so guard with // ifNotEmpty() first rather than coerce directly — same idiom as byNumericField above. order: $.map((v) => v.ifString()?.ifNotEmpty()?.toNumber().getValue()).def(100), description: $.def(''), fields: fieldShapes.length ? $.val(fieldShapes) : $.def(undefined), })) }) ) } /** * Builds the condition record (+ its sttrm_transition_condition_field rows) for a transition. * `transitionId` is the created transition's RecordId, written to the condition's * `sttrm_state_transition` FK. When the transition carries an explicit (OOB) sys_id, the condition * attaches to that existing transition. */ async function buildConditionRecords( transitionId: RecordId, cond: ObjectShape, factory: Factory, diagnostics: Diagnostics ): Promise { const condName = cond.get('name').ifString()?.getValue() ?? '' // `condition` and `conditionScript` are mutually exclusive. const hasQuery = Boolean(cond.get('condition').ifString()?.ifNotEmpty()) const hasScript = Boolean(cond.get('conditionScript').ifDefined()) if (hasQuery && hasScript) { diagnostics.error( cond.get('condition'), `Condition '${condName}' sets both 'condition' and 'conditionScript' — they are mutually exclusive.` ) } // A conditionScript provided via Now.include must resolve to a real file. if (cond.get('conditionScript').isUnresolved()) { diagnostics.error( cond.get('conditionScript').getOriginalNode(), `Unable to resolve the conditionScript reference for condition '${condName}'. Ensure the imported file exists.` ) } // Resolve the developer-supplied condition type (the platform "Requires" field). const ctRaw = cond.get('conditionType').ifString()?.ifNotEmpty()?.getValue() let conditionTypeId = '' if (ctRaw) { if (CONDITION_TYPE_BY_NAME[ctRaw]) { conditionTypeId = CONDITION_TYPE_BY_NAME[ctRaw] } else if (isGUID(ctRaw)) { conditionTypeId = ctRaw } else { diagnostics.error( cond.get('conditionType'), `Unknown conditionType '${ctRaw}'. Use one of: ${Object.keys(CONDITION_TYPE_BY_NAME).join(', ')} — or an sttrm_condition_type sys_id.` ) } } const conditionTypeRef = conditionTypeId ? await factory.createReference({ source: cond.get('conditionType'), table: 'sttrm_condition_type', guid: conditionTypeId, }) : undefined const condRecord = await factory.createRecord({ source: cond, table: STTRM_CONDITION, // $id is required on TransitionCondition — it is the sole identity mechanism (not 'name', // which is mutable display/info text and must not participate in record identity). explicitId: cond.get('$id'), properties: cond.transform(({ $ }) => ({ sttrm_state_transition: $.val(transitionId), name: $, condition: $.def(''), condition_script: $.from('conditionScript').toCdata().def(DEFAULT_CONDITION_SCRIPT), condition_type: conditionTypeRef ? $.val(conditionTypeRef) : $.val(''), active: $.def(true), order: $.from('order').toNumber().def(100), description: $.def(''), })), }) const records: Record[] = [condRecord] // One sttrm_transition_condition_field row per `fields` entry. $id is required on ConditionField — // the platform allows more than one field row with the same name on the same condition. for (const f of cond.get('fields').ifArray()?.getElements() ?? []) { const fieldObj = f.asObject() const name = fieldObj.get('name').ifString()?.ifNotEmpty() if (!name) { continue } records.push( await factory.createRecord({ source: f, table: STTRM_CONDITION_FIELD, explicitId: fieldObj.get('$id'), properties: { transition_condition: condRecord.getId(), name }, }) ) } return records } /** * Builds the sttrm_state_attribute rows for a state from its `attributes` config. */ async function buildStateAttributeRecords( stateRecord: Record, stateConfig: ObjectShape, factory: Factory, diagnostics: Diagnostics ): Promise { const records: Record[] = [] // $id is required on StateAttribute — the platform allows more than one link between the same // state and attribute. for (const a of stateConfig.get('attributes').ifArray()?.getElements() ?? []) { const obj = a.asObject() const nameOrId = obj.get('attribute').ifString()?.getValue() const active = obj.get('active').ifBoolean()?.getValue() ?? true if (!nameOrId) { continue } const attrId = ATTRIBUTE_BY_NAME[nameOrId] ?? (isGUID(nameOrId) ? nameOrId : undefined) if (!attrId) { diagnostics.error( a, `Unknown state attribute '${nameOrId}'. Use one of: ${Object.keys(ATTRIBUTE_BY_NAME).join(', ')} — or an sttrm_attribute sys_id.` ) continue } const attributeRef = await factory.createReference({ source: a, table: 'sttrm_attribute', guid: attrId }) records.push( await factory.createRecord({ source: a, table: STTRM_STATE_ATTRIBUTE, explicitId: obj.get('$id'), properties: { sttrm_state: stateRecord.getId(), sttrm_attribute: attributeRef, active, }, }) ) } return records } /** * Builds sttrm_model_user_criteria_*_mtom rows from one of a StateModel's availableFor/writableFor/ * notAvailableFor arrays. Each entry is either a raw user_criteria sys_id string or a * Record<'user_criteria'> reference. */ async function buildUserCriteriaLinkRecords( modelRecord: Record, items: Shape[], table: string, factory: Factory ): Promise { const records: Record[] = [] for (const item of items) { records.push( await factory.createRecord({ source: item, table, properties: { sttrm_model: modelRecord.getId(), user_criteria: item.isString() ? item.getValue() : item, }, }) ) } return records } /** * Shared root handler: turns a state-model record (any subclass) plus its descendant states, * transitions, and conditions into a single `StateModel({...})` call expression. */ async function modelToShape( record: Record, { descendants, transform }: { descendants: Database; transform: Transform } ) { // States that belong to this model (descendants are already scoped to this record). const stateRecords: Record[] = descendants.query(STTRM_STATE).sort(byNumericField('state_sequence', 0)) // Map state sys_id → friendly key (used to resolve transition from/to). const keyByStateId = new globalThis.Map() const usedKeys = new globalThis.Set() for (const state of stateRecords) { const label = state.get('state_label').ifString()?.getValue() ?? state.get('state_value').toString().getValue() const baseKey = stateKeyFromLabel(label) || 'state' let key = baseKey let suffix = 2 while (usedKeys.has(key)) { key = `${baseKey}_${suffix++}` } usedKeys.add(key) keyByStateId.set(state.getId().getValue(), key) } // Build the `states` object (keyed by friendly key). const statesObject: { [key: string]: ReturnType } = {} for (const state of stateRecords) { const key = keyByStateId.get(state.getId().getValue()) if (!key) { continue } // Per-state attribute links (sttrm_state_attribute) → attribute names (or raw sys_id for custom). // $id is required on StateAttribute — the platform allows more than one link between the same // state and attribute, so every entry always carries its own identity (no bare-name shorthand). const attributeEntries = descendants .query(STTRM_STATE_ATTRIBUTE, { sttrm_state: state.getId() }) .map((a: Record) => { const id = a.get('sttrm_attribute').ifString()?.getValue() ?? '' const name = ATTRIBUTE_NAME_BY_ID[id] ?? id return name ? { record: a, name } : undefined }) .filter((e): e is NonNullable => e !== undefined) .map(({ record: a, name }) => a.transform(({ $ }) => ({ $id: $.val(NowIdShape.from(a)), attribute: $.val(name), active: $.from('active').toBoolean().def(true), })) ) statesObject[key] = state.transform(({ $ }) => ({ $id: $.val(NowIdShape.from(state)), label: $.from('state_label'), value: $.from('state_value'), sequence: $.from('state_sequence') .map((v) => v.ifString()?.ifNotEmpty()?.toNumber().getValue()) .def(0), initial: $.from('initial_state').toBoolean().def(false), attributes: attributeEntries.length ? $.val(attributeEntries) : $.def(undefined), })) } // Transitions belonging to this model (collected via from_state through the state grandparent). const transitionRecords: Record[] = descendants .query(STTRM_TRANSITION) .filter((t: Record) => keyByStateId.has(t.get('from_state').ifString()?.getValue() ?? '')) const transitionShapes = ( await Promise.all( transitionRecords.map(async (trans) => { const fromKey = keyByStateId.get(trans.get('from_state').ifString()?.getValue() ?? '')! const toKey = keyByStateId.get(trans.get('to_state').ifString()?.getValue() ?? '') if (!toKey) { return undefined } const conditionRecords: Record[] = descendants .query(STTRM_CONDITION, { sttrm_state_transition: trans.getId() }) .sort(byNumericField('order', 100)) const conditionShapes = await buildConditionShapes(conditionRecords, descendants, transform) return trans.transform(({ $ }) => ({ $id: $.val(NowIdShape.from(trans)), from: $.val(fromKey), to: $.val(toKey), automatic: $.toBoolean().def(false), conditions: conditionShapes.length ? $.val(conditionShapes) : $.def(undefined), })) }) ) ).filter((s): s is NonNullable => s !== undefined) // Resolve the class-specific default flag into the generic `defaultModel` property. const modelTable = record.getTable() const defaultFlag = DEFAULT_FLAG_BY_MODEL_TABLE[modelTable] const defaultModel = defaultFlag ? record.get(defaultFlag).toBoolean().getValue() : false const isChgModel = modelTable === CHG_MODEL const isPrbTaskModel = modelTable === PRB_TASK_MODEL const isSubclass = modelTable !== STTRM_MODEL // User Criteria links (only meaningful when advancedSecurity is enabled, but always surfaced if present). const availableFor = descendants.query(STTRM_MODEL_USER_CRITERIA_READ_MTOM).map((m2m) => m2m.get('user_criteria')) const writableFor = descendants.query(STTRM_MODEL_USER_CRITERIA_WRITE_MTOM).map((m2m) => m2m.get('user_criteria')) const notAvailableFor = descendants .query(STTRM_MODEL_USER_CRITERIA_HIDE_MTOM) .map((m2m) => m2m.get('user_criteria')) return { success: true, value: new CallExpressionShape({ source: record, callee: 'StateModel', args: [ record.transform(({ $, merge }) => ({ $id: $.val(NowIdShape.from(record)), name: $, table: $.from('table_name'), stateField: $.from('state_field').def('state'), active: $.toBoolean().def(true), advancedSecurity: $.from('advanced_security').toBoolean().def(false), readRoles: $.from('read_roles').map(splitRoles).def(undefined), writeRoles: $.from('write_roles').map(splitRoles).def(undefined), availableFor: availableFor.length ? $.val(availableFor) : $.def(undefined), writableFor: writableFor.length ? $.val(writableFor) : $.def(undefined), notAvailableFor: notAvailableFor.length ? $.val(notAvailableFor) : $.def(undefined), // Who may propose Templates for this model, and who approves them. templateProposalAccess: $.from('template_proposal_access') .map((v) => { const val = v.ifString()?.ifNotEmpty()?.getValue() return val === 'read' || val === 'write' ? val : undefined }) .def(undefined), templateApprovalUsers: $.from('template_approval_users').map(splitRoles).def(undefined), templateApprovalGroups: $.from('template_approval_groups').map(splitRoles).def(undefined), // chg_model-only fields, prb_task_model-only field, and the subclass-only description [merge]: $.map(() => record.transform(({ $ }) => ({ ...(isChgModel ? { availableInUI: $.from('available_in_ui').toBoolean().def(true), recordPreset: $.from('record_preset').def(''), color: $.from('color').def(''), itilChangeProcess: $.from('itil_change_process') .map((v) => { const val = v.ifString()?.ifNotEmpty()?.getValue() return val === 'standard' || val === 'normal' || val === 'emergency' ? val : undefined }) .def(undefined), } : {}), ...(isPrbTaskModel ? { taskType: $.from('prb_task_type') .map((v) => { const val = v.ifString()?.ifNotEmpty()?.getValue() return val === 'general' || val === 'rca' || val === 'model' ? val : undefined }) .def(undefined), } : {}), ...(isSubclass ? { description: $.def('') } : {}), })) ), defaultModel: defaultModel ? $.val(true) : $.def(undefined), states: Object.keys(statesObject).length > 0 ? $.val(statesObject) : $.def(undefined), transitions: transitionShapes.length > 0 ? $.val(transitionShapes) : $.def(undefined), })), ], }), } } // Descendant relationships shared by the base sttrm_model table and every polymorphic subclass // (chg_model, prb_model, prb_task_model) — states → attributes/transitions → conditions → fields, // plus the User Criteria M2M links. const STATE_MODEL_RELATIONSHIPS = { [STTRM_STATE]: { via: 'sttrm_model', descendant: true, relationships: { [STTRM_STATE_ATTRIBUTE]: { via: 'sttrm_state', descendant: true }, [STTRM_TRANSITION]: { via: 'from_state', descendant: true, relationships: { [STTRM_CONDITION]: { via: 'sttrm_state_transition', descendant: true, relationships: { [STTRM_CONDITION_FIELD]: { via: 'transition_condition', descendant: true }, }, }, }, }, }, }, [STTRM_MODEL_USER_CRITERIA_READ_MTOM]: { via: 'sttrm_model', descendant: true }, [STTRM_MODEL_USER_CRITERIA_WRITE_MTOM]: { via: 'sttrm_model', descendant: true }, [STTRM_MODEL_USER_CRITERIA_HIDE_MTOM]: { via: 'sttrm_model', descendant: true }, } export const StateModelPlugin = Plugin.create({ name: 'StateModelPlugin', records: { // The root handler is registered for the base table and every subclass. // Identity is $id-only — these are root records with a required $id (via // Now.Internal.WithIdAndMetadata), so no coalesce is needed (see coalesce_strategy_definition). [STTRM_MODEL]: { relationships: STATE_MODEL_RELATIONSHIPS, toShape: modelToShape }, [CHG_MODEL]: { relationships: STATE_MODEL_RELATIONSHIPS, toShape: modelToShape }, [PRB_MODEL]: { relationships: STATE_MODEL_RELATIONSHIPS, toShape: modelToShape }, [PRB_TASK_MODEL]: { relationships: STATE_MODEL_RELATIONSHIPS, toShape: modelToShape }, // User Criteria M2M links — plain (model, user_criteria) pairs with no other distinguishing // field, so unlike states/transitions/attributes/fields there's no case for legitimate // duplicates here; coalesce is sufficient (matches the identical sc_cat_item_user_criteria_* // pattern in service-catalog-base.ts). [STTRM_MODEL_USER_CRITERIA_READ_MTOM]: { coalesce: ['sttrm_model', 'user_criteria'] }, [STTRM_MODEL_USER_CRITERIA_WRITE_MTOM]: { coalesce: ['sttrm_model', 'user_criteria'] }, [STTRM_MODEL_USER_CRITERIA_HIDE_MTOM]: { coalesce: ['sttrm_model', 'user_criteria'] }, }, shapes: [ { shape: CallExpressionShape, fileTypes: ['fluent'], async toRecord(callExpression, { factory, diagnostics }) { if (callExpression.getCallee() !== 'StateModel') { return { success: false } } const arg = callExpression.getArgument(0).asObject() const targetTable = arg.get('table').ifString()?.getValue() ?? '' const modelTable = detectModelTable(targetTable) const isChgModel = modelTable === CHG_MODEL const isPrbTaskModel = modelTable === PRB_TASK_MODEL const defaultFlagField = DEFAULT_FLAG_BY_MODEL_TABLE[modelTable] // --- States: validate and collect keys (states/transitions are optional for model-only updates) --- const statesObj = arg.get('states').ifDefined()?.asObject() const stateKeys = new globalThis.Set(statesObj?.keys() ?? []) // --- Validate transitions reference defined states (condition exclusivity is checked in buildConditionRecords) --- const transitionElements = arg.get('transitions').ifArray()?.getElements() ?? [] for (const transEl of transitionElements) { const trans = transEl.asObject() for (const endpoint of ['from', 'to'] as const) { const key = trans.get(endpoint).ifString()?.getValue() if (key && !stateKeys.has(key)) { diagnostics.error( trans.get(endpoint), `Transition '${endpoint}' references state '${key}' which is not defined in 'states'.` ) } } } // --- Class-specific fields only apply to certain model tables; warn if set elsewhere --- if (!isChgModel) { for (const f of ['availableInUI', 'recordPreset', 'color', 'itilChangeProcess'] as const) { if (arg.get(f).ifDefined()) { diagnostics.hint( arg.get(f), `'${f}' applies only to change_request (chg_model) state models and is ignored for table '${targetTable}'.` ) } } } if (!isPrbTaskModel && arg.get('taskType').ifDefined()) { diagnostics.hint( arg.get('taskType'), `'taskType' applies only to problem_task (prb_task_model) state models and is ignored for table '${targetTable}'.` ) } if (!defaultFlagField && arg.get('defaultModel').ifDefined()) { diagnostics.hint( arg.get('defaultModel'), `'defaultModel' is not supported for the base sttrm_model and is ignored for table '${targetTable}'.` ) } if (modelTable === STTRM_MODEL && arg.get('description').ifDefined()) { diagnostics.hint( arg.get('description'), `'description' is stored only on subclass model tables (chg_model / prb_model / prb_task_model) and is ignored for table '${targetTable}'.` ) } if (!arg.get('advancedSecurity').ifBoolean()?.getValue()) { for (const f of ['availableFor', 'writableFor', 'notAvailableFor'] as const) { if (arg.get(f).ifDefined()) { diagnostics.hint(arg.get(f), `'${f}' only takes effect when 'advancedSecurity' is enabled.`) } } } // When recordPreset is set, apply_record_preset must also be true or the platform ignores the preset. const hasRecordPreset = isChgModel && Boolean(arg.get('recordPreset').ifString()?.ifNotEmpty()) // Template approval users/groups — comma-separated sys_id lists (not role names). const templateApprovalUsers = resolveGuidList( arg.get('templateApprovalUsers').ifArray()?.getElements() ?? [], 'templateApprovalUsers', 'sys_user', diagnostics ) const templateApprovalGroups = resolveGuidList( arg.get('templateApprovalGroups').ifArray()?.getElements() ?? [], 'templateApprovalGroups', 'sys_user_group', diagnostics ) // --- Model record --- const modelRecord = await factory.createRecord({ source: callExpression, table: modelTable, // $id is required on StateModel (Now.Internal.WithIdAndMetadata) — it is the sole // identity mechanism, and also the way to target a pre-existing OOB model's sys_id. explicitId: arg.get('$id'), properties: arg.transform(({ $, merge }) => ({ name: $, table_name: $.from('table'), state_field: $.from('stateField').def('state'), active: $.from('active').def(true), advanced_security: $.from('advancedSecurity').def(false), read_roles: $.from('readRoles').map(joinRoles).def(undefined), write_roles: $.from('writeRoles').map(joinRoles).def(undefined), template_proposal_access: $.from('templateProposalAccess').def(''), template_approval_users: $.val(templateApprovalUsers).def(''), template_approval_groups: $.val(templateApprovalGroups).def(''), // chg_model-only fields, prb_task_model-only field, class-specific default flag, // and the subclass-only description [merge]: $.map(() => arg.transform(({ $ }) => ({ ...(isChgModel ? { available_in_ui: $.from('availableInUI').def(true), record_preset: $.from('recordPreset').def(''), // Auto-enable apply_record_preset so the platform actually applies the preset. apply_record_preset: $.val(hasRecordPreset), color: $.from('color').def(''), itil_change_process: $.from('itilChangeProcess').def(''), } : {}), ...(isPrbTaskModel ? { prb_task_type: $.from('taskType').def('') } : {}), ...(defaultFlagField ? { [defaultFlagField]: $.from('defaultModel').def(false) } : {}), ...(modelTable !== STTRM_MODEL ? { description: $.from('description').def('') } : {}), })) ), })), }) // --- User Criteria links (availableFor/writableFor/notAvailableFor) --- const userCriteriaRecords: Record[] = [ ...(await buildUserCriteriaLinkRecords( modelRecord, arg.get('availableFor').ifArray()?.getElements() ?? [], STTRM_MODEL_USER_CRITERIA_READ_MTOM, factory )), ...(await buildUserCriteriaLinkRecords( modelRecord, arg.get('writableFor').ifArray()?.getElements() ?? [], STTRM_MODEL_USER_CRITERIA_WRITE_MTOM, factory )), ...(await buildUserCriteriaLinkRecords( modelRecord, arg.get('notAvailableFor').ifArray()?.getElements() ?? [], STTRM_MODEL_USER_CRITERIA_HIDE_MTOM, factory )), ] // --- State records (build key → record map) --- const stateRecordByKey = new globalThis.Map() const stateRecords: Record[] = [] const stateAttributeRecords: Record[] = [] if (statesObj) { for (const key of statesObj.keys()) { const stateConfig = statesObj.get(key).asObject() const stateRecord = await factory.createRecord({ source: stateConfig, table: STTRM_STATE, // $id is required on State — the platform allows more than one state with // the same value on the same model. explicitId: stateConfig.get('$id'), properties: stateConfig.transform(({ $ }) => ({ sttrm_model: $.val(modelRecord.getId()), // sttrm_state.state_label is read_only: true in sys_dictionary, but the // platform accepts it via INSERT_OR_UPDATE at the REST layer. Write it // explicitly so the label persists on install. If the platform ever enforces // the read-only flag on the REST import path, this line is the one to guard. state_label: $.from('label'), state_value: $.from('value').toString(), state_sequence: $.from('sequence').toNumber().def(0), initial_state: $.from('initial').def(false), })), }) stateRecordByKey.set(key, stateRecord) stateRecords.push(stateRecord) stateAttributeRecords.push( ...(await buildStateAttributeRecords(stateRecord, stateConfig, factory, diagnostics)) ) } } // --- Transition + condition records --- const transitionRecords: Record[] = [] const conditionRecords: Record[] = [] for (const transEl of transitionElements) { const trans = transEl.asObject() const fromKey = trans.get('from').ifString()?.getValue() ?? '' const toKey = trans.get('to').ifString()?.getValue() ?? '' const fromRecord = stateRecordByKey.get(fromKey) const toRecord = stateRecordByKey.get(toKey) if (!fromRecord || !toRecord) { // Already reported via diagnostics above; skip creating an orphaned transition. continue } // The platform's "Populate transition name" business rule does not fire on SDK/REST // install, so set the name explicitly using the OOB convention: "{fromLabel} to {toLabel}". const fromLabel = statesObj?.get(fromKey).asObject().get('label').ifString()?.getValue() ?? fromKey const toLabel = statesObj?.get(toKey).asObject().get('label').ifString()?.getValue() ?? toKey const transitionName = `${fromLabel} to ${toLabel}` const transRecord = await factory.createRecord({ source: trans, table: STTRM_TRANSITION, // $id is required on Transition — the platform allows more than one transition // between the same two states (e.g. one automatic, one manual). explicitId: trans.get('$id'), properties: trans.transform(({ $ }) => ({ from_state: $.val(fromRecord.getId()), to_state: $.val(toRecord.getId()), automatic: $.def(false), name: $.val(transitionName), })), }) transitionRecords.push(transRecord) const conditions = trans.get('conditions').ifArray()?.getElements() ?? [] for (const condEl of conditions) { const cond = condEl.asObject() conditionRecords.push( ...(await buildConditionRecords(transRecord.getId(), cond, factory, diagnostics)) ) } } return { success: true, value: modelRecord.with( ...userCriteriaRecords, ...stateRecords, ...stateAttributeRecords, ...transitionRecords, ...conditionRecords ), } }, }, ], })