@jacebenson/jsn 4.3.8 → 4.3.9-dev.62

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.
@@ -1,4 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { isDeepStrictEqual } from 'node:util';
3
+ import { withFlowSafeEdit } from './flow-safe-edit.js';
2
4
  import { assertSafeExactMatch } from './helpers.js';
3
5
  import { createVerifiedFlowVersion } from './flow-versioning.js';
4
6
  import {
@@ -493,6 +495,15 @@ function wrappedValue(value) {
493
495
  return { schemaless: false, schemalessValue: '', value };
494
496
  }
495
497
 
498
+ function maskInputValue(input) {
499
+ const copy = clone(input);
500
+ for (const field of ['value', 'displayValue']) {
501
+ if (copy[field] && typeof copy[field] === 'object' && Object.hasOwn(copy[field], 'value')) copy[field].value = null;
502
+ else if (Object.hasOwn(copy, field)) copy[field] = null;
503
+ }
504
+ return copy;
505
+ }
506
+
496
507
  function buildActionUpdate(existing, requested, index) {
497
508
  const existingInputs = new Set(inputList(existing).map(input => stringValue(input?.name)));
498
509
  for (const name of Object.keys(requested.inputs)) {
@@ -555,7 +566,7 @@ function readFlowRecord(sdk, identifier) {
555
566
  if (!exactIdentifier) throw new Error('Flow identifier must be an exact sys_id or name');
556
567
  assertSafeExactMatch(exactIdentifier);
557
568
  const query = isSysId(exactIdentifier) ? `sys_id=${exactIdentifier}` : `name=${exactIdentifier}`;
558
- return sdk.list('sys_hub_flow', new URLSearchParams({ sysparm_query: query, sysparm_limit: '2', sysparm_display_value: 'all', sysparm_fields: 'sys_id,name,scope,active,status,type' }));
569
+ return sdk.list('sys_hub_flow', new URLSearchParams({ sysparm_query: query, sysparm_limit: '2', sysparm_display_value: 'all', sysparm_fields: 'sys_id,name,sys_scope,scope,active,status,type,sys_updated_on' }));
559
570
  }
560
571
 
561
572
  async function resolveDraftFlow(sdk, identifier) {
@@ -565,11 +576,12 @@ async function resolveDraftFlow(sdk, identifier) {
565
576
  const flowId = idValue(flow.sys_id);
566
577
  const flowName = stringValue(flow.name);
567
578
  if (flowId === PROTECTED_FLOW_ID || flowName === PROTECTED_FLOW_NAME) throw new Error(`Protected flow ${PROTECTED_FLOW_NAME} cannot be updated`);
568
- if (String(flow.active?.value ?? flow.active ?? '').toLowerCase() === 'true' || String(flow.status?.value ?? flow.status ?? '').toLowerCase() !== 'draft') throw new Error(`Flow ${flowId} is not an inactive Draft; refusing update`);
579
+ if (String(flow.active?.value ?? flow.active ?? '').toLowerCase() !== 'false' || String(flow.status?.value ?? flow.status ?? '').toLowerCase() !== 'draft') throw new Error(`Flow ${flowId} is not an inactive Draft; refusing update. Published checkout is not verified by this CLI. Use Flow Designer to check out and edit published flows; save, publish and execution are separate operations.`);
569
580
  return { flow, flowId, flowName };
570
581
  }
571
582
 
572
583
  function updatedTrigger(flow, manifest, descriptor) {
584
+ if (findArray(flow, 'triggerInstances').length !== 1) throw new Error('Saved flow must have exactly one trigger for trigger updates');
573
585
  const existing = clone(findArray(flow, 'triggerInstances')[0]);
574
586
  if (!existing) throw new Error('Saved flow has no trigger instance');
575
587
  const existingMetadata = parseMaybeJson(existing.metadata, null);
@@ -595,14 +607,23 @@ function updatedTrigger(flow, manifest, descriptor) {
595
607
  }
596
608
  const id = findId(existing, ['sysId', 'sys_id', 'id', 'triggerId']);
597
609
  if (!id) throw new Error('Saved record trigger has no usable identity');
598
- const update = { id, inputs };
610
+ const changed = inputs.filter(input => manifest.trigger[input.name] !== undefined);
611
+ const update = { id, inputs: changed.map(input => ({
612
+ ...(input.id ? { id: input.id } : {}),
613
+ name: input.name,
614
+ displayField: input.displayField,
615
+ scriptActive: input.scriptActive,
616
+ ...(input.parameter ? { parameter: flowDesignParameter(input.parameter) } : {}),
617
+ value: input.value,
618
+ displayValue: input.displayValue,
619
+ })) };
599
620
  if (manifest.trigger.condition !== undefined) {
600
621
  // The instance re-derives predicate metadata from the condition input; see
601
622
  // DEFAULT_TRIGGER_METADATA. The read-back check below still proves the
602
623
  // derived predicate matches what was asked for.
603
624
  update.metadata = DEFAULT_TRIGGER_METADATA;
604
625
  } else if (Object.prototype.hasOwnProperty.call(existing, 'metadata')) {
605
- update.metadata = existing.metadata;
626
+ update.metadata = typeof existing.metadata === 'string' ? existing.metadata : JSON.stringify(existing.metadata);
606
627
  }
607
628
  return { update, currentTable: table };
608
629
  }
@@ -771,16 +792,26 @@ export async function updateFlowFromManifest(sdk, instance, identifier, inputMan
771
792
  const manifest = validateFlowUpdateManifest(inputManifest);
772
793
  const target = await resolveDraftFlow(sdk, identifier);
773
794
  const flowId = target.flowId;
774
- const scope = stringValue(manifest.scope ?? target.flow.scope);
795
+ const targetScope = stringValue(target.flow.sys_scope) || stringValue(target.flow.scope);
796
+ const scope = manifest.scope ?? targetScope;
775
797
  const readUpdateSet = options.readUpdateSet || (async () => null);
776
798
  if (!scope) throw new Error(`Flow ${flowId} has no transaction scope`);
777
- if (manifest.scope !== undefined && stringValue(target.flow.scope) && manifest.scope !== stringValue(target.flow.scope)) throw new Error(`Flow ${flowId} scope does not match the update manifest`);
778
- const beforeDefinition = await readProcessFlow(sdk, instance, flowId, scope);
799
+ if (manifest.scope !== undefined && targetScope && manifest.scope !== targetScope) throw new Error(`Flow ${flowId} scope does not match the update manifest`);
800
+ let beforeDefinition = await readProcessFlow(sdk, instance, flowId, scope);
801
+ // A legacy ProcessFlow read can expose the preceding version's timestamp
802
+ // immediately after Autosave. Never substitute the newer Table timestamp into
803
+ // an older definition: reread the whole document, then let safeEdit check it.
804
+ const recordUpdated = stringValue(target.flow.sys_updated_on);
805
+ if (recordUpdated && beforeDefinition.updated !== recordUpdated) {
806
+ beforeDefinition = await readProcessFlow(sdk, instance, flowId, scope);
807
+ if (beforeDefinition.updated !== recordUpdated) throw new Error(`Flow ${flowId} definition is stale relative to its record; no edit session or content mutation attempted`);
808
+ }
779
809
  const snapshots = [];
780
810
  const triggerUpdates = [];
781
811
  const actionUpdates = [];
782
812
  const changedActionInputs = new Map();
783
813
  const actionTargets = new Map();
814
+ const changes = [];
784
815
  let triggerUpdate;
785
816
  if (manifest.trigger !== undefined) {
786
817
  const currentTrigger = findArray(beforeDefinition, 'triggerInstances')[0];
@@ -790,6 +821,9 @@ export async function updateFlowFromManifest(sdk, instance, identifier, inputMan
790
821
  : await readTableDescriptor(sdk, manifest.trigger.table ?? currentTable);
791
822
  triggerUpdate = updatedTrigger(beforeDefinition, manifest, descriptor).update;
792
823
  triggerUpdates.push(triggerUpdate);
824
+ for (const input of ['table', 'condition']) {
825
+ if (manifest.trigger[input] !== undefined) changes.push({ component: 'trigger', input, before: inputRawValue(inputList(currentTrigger).find(field => field.name === input)), after: manifest.trigger[input] });
826
+ }
793
827
  }
794
828
  const existingActions = findExistingActions(beforeDefinition);
795
829
  for (const [index, requested] of (manifest.actions || []).entries()) {
@@ -797,78 +831,110 @@ export async function updateFlowFromManifest(sdk, instance, identifier, inputMan
797
831
  const update = buildActionUpdate(existing, requested, index);
798
832
  actionUpdates.push(update);
799
833
  const uiId = stringValue(existing.uiUniqueIdentifier || existing.ui_unique_identifier);
834
+ if (actionTargets.has(uiId)) throw new Error(`Duplicate action update for order ${requested.order}; combine input changes in one action entry`);
800
835
  actionTargets.set(uiId, existing);
801
836
  changedActionInputs.set(uiId, new Set(Object.keys(requested.inputs)));
837
+ for (const [input, after] of Object.entries(requested.inputs)) changes.push({ component: 'action', order: requested.order, type: requested.type, input, before: inputRawValue(inputList(existing).find(field => field.name === input)), after });
802
838
  }
803
839
  if (triggerUpdates.length === 0 && actionUpdates.length === 0) throw new Error(`Flow ${flowId} update has no supported changes`);
804
-
805
- try {
806
- const patchResponse = await mutationContext('trigger_and_actions_update', readUpdateSet, snapshots, () => sdk.request(`${instance}${GRAPHQL_ENDPOINT}`, {
807
- method: 'POST',
808
- body: JSON.stringify({ query: buildFlowUpdateMutation({ flowId, triggerUpdates, actionUpdates }), variables: {} }),
809
- }), updateSet => readUpdateSetCapture(sdk, updateSet, flowId));
810
- assertGraphQLSuccess(patchResponse, 'Flow trigger/action update');
811
- const processflow = await readProcessFlow(sdk, instance, flowId, scope);
812
- const readbackTrigger = findArray(processflow, 'triggerInstances')[0];
813
- if (triggerUpdate) {
814
- if (!readbackTrigger || findId(readbackTrigger, ['sysId', 'sys_id', 'id', 'triggerId']) !== triggerUpdate.id) throw new Error(`Flow ${flowId} trigger update was not readable after mutation`);
815
- const beforeTrigger = findArray(beforeDefinition, 'triggerInstances')[0];
816
- const preserveBefore = { ...beforeTrigger, inputs: undefined, metadata: undefined };
817
- const preserveAfter = { ...readbackTrigger, inputs: undefined, metadata: undefined };
818
- if (JSON.stringify(preserveBefore) !== JSON.stringify(preserveAfter)) throw new Error(`Flow ${flowId} trigger readback changed fields omitted by the manifest`);
819
- const readbackInputs = inputList(readbackTrigger);
820
- const beforeInputs = inputList(beforeTrigger);
821
- if (readbackInputs.length !== beforeInputs.length) throw new Error(`Flow ${flowId} trigger input count changed during update`);
822
- const requestedInputs = new Set();
823
- if (manifest.trigger.table !== undefined) requestedInputs.add('table');
824
- if (manifest.trigger.condition !== undefined) requestedInputs.add('condition');
825
- for (const beforeInput of beforeInputs) {
826
- const afterInput = readbackInputs.find(input => input.name === beforeInput.name);
827
- if (!afterInput) throw new Error(`Flow ${flowId} trigger lost input ${beforeInput.name} during update`);
828
- if (!requestedInputs.has(beforeInput.name) && JSON.stringify(beforeInput) !== JSON.stringify(afterInput)) throw new Error(`Flow ${flowId} trigger changed omitted input ${beforeInput.name}`);
840
+ if (options.preview) return { preview: true, flow: { id: flowId, name: target.flowName, status: 'draft', active: false }, changes, lifecycle: { published: false, executed: false } };
841
+
842
+ return withFlowSafeEdit(sdk, instance, flowId, beforeDefinition.updated, async () => {
843
+ let completedStage = 'validated';
844
+ try {
845
+ const patchResponse = await mutationContext('trigger_and_actions_update', readUpdateSet, snapshots, () => sdk.request(`${instance}${GRAPHQL_ENDPOINT}`, {
846
+ method: 'POST',
847
+ body: JSON.stringify({ query: buildFlowUpdateMutation({ flowId, triggerUpdates, actionUpdates }), variables: {} }),
848
+ }), updateSet => readUpdateSetCapture(sdk, updateSet, flowId));
849
+ assertGraphQLSuccess(patchResponse, 'Flow trigger/action update');
850
+ completedStage = 'patch_acknowledged';
851
+ const processflow = await readProcessFlow(sdk, instance, flowId, scope);
852
+ // Only requested values may differ. Preserve array order and opaque fields.
853
+ for (const field of ['flowVariables', 'flowLogicInstances', 'subFlowInstances', 'flowInputs', 'flowOutputs', 'inputs', 'outputs', 'stages']) {
854
+ if (!isDeepStrictEqual(beforeDefinition[field], processflow[field])) throw new Error(`Flow ${flowId} changed omitted ${field}; preservation failed`);
829
855
  }
830
- if (manifest.trigger.table !== undefined && inputRawValue(readbackInputs.find(input => input.name === 'table')) !== manifest.trigger.table) throw new Error(`Flow ${flowId} trigger table readback did not match the requested value`);
831
- if (manifest.trigger.condition !== undefined) {
832
- if (inputRawValue(readbackInputs.find(input => input.name === 'condition')) !== manifest.trigger.condition) throw new Error(`Flow ${flowId} trigger condition readback did not match the requested value`);
833
- assertPredicateMetadataMatches(readbackTrigger.metadata, manifest.trigger.condition, `Flow ${flowId} trigger`);
834
- const beforeMetadata = stripPredicates(parseMaybeJson(beforeTrigger.metadata, {}));
835
- const afterMetadata = stripPredicates(parseMaybeJson(readbackTrigger.metadata, {}));
836
- if (JSON.stringify(beforeMetadata) !== JSON.stringify(afterMetadata)) throw new Error(`Flow ${flowId} trigger metadata changed fields omitted by the manifest`);
837
- } else if (JSON.stringify(parseMaybeJson(beforeTrigger.metadata, {})) !== JSON.stringify(parseMaybeJson(readbackTrigger.metadata, {}))) {
838
- throw new Error(`Flow ${flowId} trigger metadata changed when the condition was omitted`);
856
+ if (!triggerUpdate && !isDeepStrictEqual(beforeDefinition.triggerInstances, processflow.triggerInstances)) throw new Error(`Flow ${flowId} changed omitted triggerInstances`);
857
+ const maskActions = definition => findExistingActions(definition).map(action => {
858
+ const copy = clone(action);
859
+ const changed = changedActionInputs.get(stringValue(action.uiUniqueIdentifier || action.ui_unique_identifier));
860
+ for (const [index, input] of inputList(copy).entries()) if (changed?.has(input.name)) inputList(copy)[index] = maskInputValue(input);
861
+ return copy;
862
+ });
863
+ if (!isDeepStrictEqual(maskActions(beforeDefinition), maskActions(processflow))) throw new Error(`Flow ${flowId} changed omitted action fields; preservation failed`);
864
+ const readbackTrigger = findArray(processflow, 'triggerInstances')[0];
865
+ if (triggerUpdate) {
866
+ if (!readbackTrigger || findId(readbackTrigger, ['sysId', 'sys_id', 'id', 'triggerId']) !== triggerUpdate.id) throw new Error(`Flow ${flowId} trigger update was not readable after mutation`);
867
+ const beforeTrigger = findArray(beforeDefinition, 'triggerInstances')[0];
868
+ const preserveBefore = { ...beforeTrigger, inputs: undefined, metadata: undefined };
869
+ const preserveAfter = { ...readbackTrigger, inputs: undefined, metadata: undefined };
870
+ if (JSON.stringify(preserveBefore) !== JSON.stringify(preserveAfter)) throw new Error(`Flow ${flowId} trigger readback changed fields omitted by the manifest`);
871
+ const readbackInputs = inputList(readbackTrigger);
872
+ const beforeInputs = inputList(beforeTrigger);
873
+ if (readbackInputs.length !== beforeInputs.length) throw new Error(`Flow ${flowId} trigger input count changed during update`);
874
+ const requestedInputs = new Set();
875
+ if (manifest.trigger.table !== undefined) requestedInputs.add('table');
876
+ if (manifest.trigger.condition !== undefined) requestedInputs.add('condition');
877
+ const maskTriggerInputs = inputs => inputs.map(input => {
878
+ if (!requestedInputs.has(input.name)) return input;
879
+ const copy = maskInputValue(input);
880
+ delete copy.displayField;
881
+ // Live table saves materialize only this absent display label as ''.
882
+ // Do not collapse named values, defaults or arbitrary empty fields.
883
+ if (copy.parameter?.type_label === '') delete copy.parameter.type_label;
884
+ return copy;
885
+ });
886
+ if (!isDeepStrictEqual(maskTriggerInputs(beforeInputs), maskTriggerInputs(readbackInputs))) throw new Error(`Flow ${flowId} trigger input preservation failed`);
887
+ for (const beforeInput of beforeInputs) {
888
+ const afterInput = readbackInputs.find(input => input.name === beforeInput.name);
889
+ if (!afterInput) throw new Error(`Flow ${flowId} trigger lost input ${beforeInput.name} during update`);
890
+ if (!requestedInputs.has(beforeInput.name) && JSON.stringify(beforeInput) !== JSON.stringify(afterInput)) throw new Error(`Flow ${flowId} trigger changed omitted input ${beforeInput.name}`);
891
+ }
892
+ if (manifest.trigger.table !== undefined && inputRawValue(readbackInputs.find(input => input.name === 'table')) !== manifest.trigger.table) throw new Error(`Flow ${flowId} trigger table readback did not match the requested value`);
893
+ if (manifest.trigger.condition !== undefined) {
894
+ if (inputRawValue(readbackInputs.find(input => input.name === 'condition')) !== manifest.trigger.condition) throw new Error(`Flow ${flowId} trigger condition readback did not match the requested value`);
895
+ assertPredicateMetadataMatches(readbackTrigger.metadata, manifest.trigger.condition, `Flow ${flowId} trigger`);
896
+ const beforeMetadata = stripPredicates(parseMaybeJson(beforeTrigger.metadata, {}));
897
+ const afterMetadata = stripPredicates(parseMaybeJson(readbackTrigger.metadata, {}));
898
+ if (JSON.stringify(beforeMetadata) !== JSON.stringify(afterMetadata)) throw new Error(`Flow ${flowId} trigger metadata changed fields omitted by the manifest`);
899
+ } else if (JSON.stringify(parseMaybeJson(beforeTrigger.metadata, {})) !== JSON.stringify(parseMaybeJson(readbackTrigger.metadata, {}))) {
900
+ throw new Error(`Flow ${flowId} trigger metadata changed when the condition was omitted`);
901
+ }
839
902
  }
840
- }
841
- const readbackActions = findExistingActions(processflow);
842
- for (const update of actionUpdates) {
843
- const uiId = stringValue(update.uiUniqueIdentifier);
844
- const readback = readbackActions.find(action => stringValue(action.uiUniqueIdentifier || action.ui_unique_identifier) === uiId);
845
- if (!readback) throw new Error(`Flow ${flowId} Action ${uiId} was not readable after mutation`);
846
- const before = actionTargets.get(uiId);
847
- const changed = changedActionInputs.get(uiId) || new Set();
848
- for (const input of inputList(before)) {
849
- const afterInput = inputList(readback).find(candidate => candidate.name === input.name);
850
- if (!afterInput) throw new Error(`Flow ${flowId} Action ${uiId} lost input ${input.name} during update`);
851
- if (!changed.has(input.name) && JSON.stringify(input) !== JSON.stringify(afterInput)) throw new Error(`Flow ${flowId} Action ${uiId} changed omitted input ${input.name}`);
852
- if (changed.has(input.name) && inputRawValue(afterInput) !== inputRawValue(update.inputs.find(candidate => candidate.name === input.name))) throw new Error(`Flow ${flowId} Action ${uiId} input ${input.name} did not match the requested value`);
903
+ const readbackActions = findExistingActions(processflow);
904
+ for (const update of actionUpdates) {
905
+ const uiId = stringValue(update.uiUniqueIdentifier);
906
+ const readback = readbackActions.find(action => stringValue(action.uiUniqueIdentifier || action.ui_unique_identifier) === uiId);
907
+ if (!readback) throw new Error(`Flow ${flowId} Action ${uiId} was not readable after mutation`);
908
+ const before = actionTargets.get(uiId);
909
+ const changed = changedActionInputs.get(uiId) || new Set();
910
+ for (const input of inputList(before)) {
911
+ const afterInput = inputList(readback).find(candidate => candidate.name === input.name);
912
+ if (!afterInput) throw new Error(`Flow ${flowId} Action ${uiId} lost input ${input.name} during update`);
913
+ if (!changed.has(input.name) && JSON.stringify(input) !== JSON.stringify(afterInput)) throw new Error(`Flow ${flowId} Action ${uiId} changed omitted input ${input.name}`);
914
+ if (changed.has(input.name) && inputRawValue(afterInput) !== inputRawValue(update.inputs.find(candidate => candidate.name === input.name))) throw new Error(`Flow ${flowId} Action ${uiId} input ${input.name} did not match the requested value`);
915
+ }
853
916
  }
917
+ completedStage = 'definition_verified';
918
+ const { version } = await mutationContext('autosave_version', readUpdateSet, snapshots, () => createVerifiedFlowVersion(sdk, {
919
+ instance, flowId, scope, type: 'Autosave', annotation: 'JSN flow update',
920
+ }), updateSet => readUpdateSetCapture(sdk, updateSet, flowId));
921
+ completedStage = 'autosave_verified';
922
+ const selectedUpdateSet = await readUpdateSet();
923
+ const updateSetCapture = await readUpdateSetCapture(sdk, selectedUpdateSet, flowId);
924
+ return {
925
+ flow: { id: flowId, name: target.flowName, status: 'draft', active: false },
926
+ changes,
927
+ processflow,
928
+ definition_readback: { available: true, status: 'read_back', flow_id: flowId },
929
+ version,
930
+ update_set_capture: updateSetCapture,
931
+ update_set_mutations: snapshots,
932
+ lifecycle: { published: false, supported: ['draft', 'autosave'], deferred: ['publish/activation', 'Flow Logic', 'subflows', 'flow variables', 'error handling'] },
933
+ };
934
+ } catch (error) {
935
+ throw new Error(`Flow ${flowId} update incomplete; last completed stage: ${completedStage}. Changes may already be persisted; inspect this exact flow before retrying. No rollback, publish or execution attempted. ${error.message}; reconciliation: ${JSON.stringify(snapshots)}`, { cause: error });
854
936
  }
855
- const { version } = await mutationContext('autosave_version', readUpdateSet, snapshots, () => createVerifiedFlowVersion(sdk, {
856
- instance, flowId, scope, type: 'Autosave', annotation: 'JSN flow update',
857
- }), updateSet => readUpdateSetCapture(sdk, updateSet, flowId));
858
- const selectedUpdateSet = await readUpdateSet();
859
- const updateSetCapture = await readUpdateSetCapture(sdk, selectedUpdateSet, flowId);
860
- return {
861
- flow: { id: flowId, name: target.flowName, status: 'draft', active: false },
862
- processflow,
863
- definition_readback: { available: true, status: 'read_back', flow_id: flowId },
864
- version,
865
- update_set_capture: updateSetCapture,
866
- update_set_mutations: snapshots,
867
- lifecycle: { published: false, supported: ['draft', 'autosave'], deferred: ['publish/activation', 'Flow Logic', 'subflows', 'flow variables', 'error handling'] },
868
- };
869
- } catch (error) {
870
- throw new Error(`Flow ${flowId} update incomplete: ${error.message}; reconciliation: ${JSON.stringify(snapshots)}`, { cause: error });
871
- }
937
+ });
872
938
  }
873
939
 
874
940
  export async function readCurrentUpdateSet(sdk) {
@@ -370,7 +370,7 @@ export async function savedFlowDefinition(app, identifier) {
370
370
  const { resolveRecord, unwrapSysId } = await import('./resolve-record.js');
371
371
  const record = await resolveRecord(app.sdk, { table: 'sys_hub_flow', identifier, matchField: 'name', resource: 'flow' });
372
372
  const id = unwrapSysId(record);
373
- const scope = rawField(record.scope);
373
+ const scope = rawField(record.sys_scope) || rawField(record.scope);
374
374
  if (!scope) throw errUsage('Saved flow has no raw transaction scope');
375
375
  return { record, definition: await readProcessFlow(app.sdk, app.getEffectiveInstance(), id, scope) };
376
376
  }
@@ -396,7 +396,7 @@ export async function guidedFlowEdit(argv, app) {
396
396
  const actionType = rawField(action.actionTypeSysId || action.action_type_sys_id || action.actionType?.sys_id || action.actionType?.sysId);
397
397
  let inputSchema = { ...field, type: field.type || field.parameter?.type };
398
398
  if (!inputSchema.type) {
399
- const { schema } = await readActionDefinition(app.sdk, app.getEffectiveInstance(), actionType, rawField(record.scope), { requireTypes: true });
399
+ const { schema } = await readActionDefinition(app.sdk, app.getEffectiveInstance(), actionType, rawField(record.sys_scope) || rawField(record.scope), { requireTypes: true });
400
400
  inputSchema = schema.find(candidate => candidate.name === field.name);
401
401
  if (!inputSchema) throw errUsage(`Saved action input ${field.name} is absent from its provider schema`);
402
402
  }
@@ -80,9 +80,21 @@ export async function publishFlows(sdk, flowIDs = [], actionIDs = []) {
80
80
  /** Publish one flow through the ProcessFlow snapshot/version lifecycle. */
81
81
  export async function publishFlowLifecycle(sdk, instance, flowId) {
82
82
  const flowRecord = sdk.get ? await sdk.get('sys_hub_flow', flowId) : null;
83
- const scope = str(flowRecord, 'scope');
84
- if (!scope) throw new Error(`Flow ${flowId} has no transaction scope; refusing to publish`);
85
- const definitionResponse = await sdk.request(`${instance}${PROCESSFLOW_PATH}/${encodeURIComponent(flowId)}?sysparm_transaction_scope=${encodeURIComponent(scope)}`, { method: 'GET' });
83
+ const flowPath = `${instance}${PROCESSFLOW_PATH}/${encodeURIComponent(flowId)}`;
84
+ // sys_hub_flow.scope is often omitted by the Table API for newly created
85
+ // flows. The ProcessFlow definition carries the application scope needed by
86
+ // its snapshot and version endpoints, so read it before constructing either.
87
+ let scope = str(flowRecord, 'scope');
88
+ let definitionResponse;
89
+ if (!scope) {
90
+ definitionResponse = await sdk.request(flowPath, { method: 'GET' });
91
+ const initialDefinition = definitionResponse?.result?.data ?? definitionResponse?.data?.result?.data ?? definitionResponse?.data ?? definitionResponse?.result ?? definitionResponse;
92
+ scope = str(initialDefinition, 'scope');
93
+ if (!scope) throw new Error(`Flow ${flowId} has no transaction scope; refusing to publish`);
94
+ }
95
+ if (!definitionResponse) {
96
+ definitionResponse = await sdk.request(`${flowPath}?sysparm_transaction_scope=${encodeURIComponent(scope)}`, { method: 'GET' });
97
+ }
86
98
  const definition = definitionResponse?.result?.data ?? definitionResponse?.data?.result?.data ?? definitionResponse?.data ?? definitionResponse?.result ?? definitionResponse;
87
99
  if (!definition || typeof definition !== 'object') throw new Error(`Flow definition could not be read for ${flowId}`);
88
100
  const definitionScope = str(definition, 'scope');
@@ -0,0 +1,48 @@
1
+ // Flow Designer's safeEdit lease is separate from publishing and artifact deletion.
2
+ // The provider exposes a user-level lock, not an operation token: refuse even a
3
+ // same-user existing lease so this CLI cannot release an open designer session.
4
+ export async function flowSafeEditRequest(sdk, instance, flowId, operation, lastUpdated) {
5
+ const id = JSON.stringify(flowId);
6
+ const input = operation === 'upsert' ? `upsert:{flowId:${id} lastUpdated:${JSON.stringify(lastUpdated)}}` : `${operation}:${id}`;
7
+ const selection = operation === 'delete' ? 'deleteResult{deleteSuccess id}' : `status{canUserEdit currentEditor${operation === 'upsert' ? ' clientFlowStale' : ''}}`;
8
+ const query = `${operation === 'read' ? 'query' : 'mutation'} {global {snFlowDesigner {safeEdit(safeEditInput:{${input}}){${selection}}}}}`;
9
+ const response = await sdk.request(`${instance}/api/now/graphql`, { method: 'POST', body: JSON.stringify({ query, variables: {} }) });
10
+ if (response.errors?.length) throw new Error(`Flow safe edit ${operation}: ${response.errors.map(error => error.message).join('; ')}`);
11
+ const result = response.data?.global?.snFlowDesigner?.safeEdit;
12
+ if (!result) throw new Error(`Flow safe edit ${operation} returned no result`);
13
+ return result;
14
+ }
15
+
16
+ export async function withFlowSafeEdit(sdk, instance, flowId, lastUpdated, operation) {
17
+ if (typeof lastUpdated !== 'string' || !lastUpdated) throw new Error(`Flow ${flowId} has no authoritative updated timestamp; safe edit refused`);
18
+ const read = async () => {
19
+ const { status } = await flowSafeEditRequest(sdk, instance, flowId, 'read');
20
+ if (!status || typeof status.canUserEdit !== 'boolean' || !(status.currentEditor == null || typeof status.currentEditor === 'string')) throw new Error('Unreadable safe edit status');
21
+ return status;
22
+ };
23
+ const before = await read();
24
+ if (before.canUserEdit || before.currentEditor) throw new Error(`Flow ${flowId} already has an existing edit session/editor; close it in Flow Designer before retrying. No lock changed.`);
25
+ let status;
26
+ try {
27
+ ({ status } = await flowSafeEditRequest(sdk, instance, flowId, 'upsert', lastUpdated));
28
+ } catch (error) {
29
+ throw new Error(`Flow ${flowId} safe edit acquisition is uncertain: ${error.message}. No content write attempted; no release attempted because ownership is unconfirmed. Inspect the exact flow/session before retrying.`, { cause: error });
30
+ }
31
+ // A denied or ambiguous upsert is not proof we own a lease. Never delete it.
32
+ if (status?.canUserEdit !== true || status.currentEditor) throw new Error(`Flow ${flowId} safe edit acquisition denied or ambiguous${status?.clientFlowStale === true ? ' (client flow is stale)' : ' (another editor may own it)'}; no content write or release attempted`);
33
+ let result, failure;
34
+ try {
35
+ if (status.clientFlowStale !== false && status.clientFlowStale !== null) throw new Error(`Flow ${flowId} is stale or has unreadable freshness; reread before retrying`);
36
+ result = await operation();
37
+ } catch (error) { failure = error; }
38
+ try {
39
+ const { deleteResult } = await flowSafeEditRequest(sdk, instance, flowId, 'delete');
40
+ if (deleteResult?.deleteSuccess !== true || deleteResult.id !== flowId) throw new Error('provider did not confirm release');
41
+ const after = await read();
42
+ if (after.canUserEdit || after.currentEditor) throw new Error('release readback still reports an edit session');
43
+ } catch (error) {
44
+ throw new Error(`${failure ? `${failure.message}; ` : 'Content operation completed; '}Flow ${flowId} safe edit release failed: ${error.message}. Inspect the exact flow/session before retrying.`, { cause: error });
45
+ }
46
+ if (failure) throw failure;
47
+ return result;
48
+ }
@@ -0,0 +1,67 @@
1
+ // Reusable, provider-independent impact analysis. No CLI, prompts or writes.
2
+ const raw = value => String(value?.value ?? value ?? '');
3
+ const BOOKKEEPING_FIELDS = new Set(['sys_id', 'sys_created_on', 'sys_created_by', 'sys_updated_on',
4
+ 'sys_updated_by', 'sys_mod_count', 'sys_recorded_at', 'sys_update_name']);
5
+ export const isImpactField = field => !BOOKKEEPING_FIELDS.has(field);
6
+ const decode = value => value.replace(/&lt;/g, '<').replace(/&gt;/g, '>')
7
+ .replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, '&');
8
+
9
+ // Deliberately accepts only the observed flat record_update payload shape.
10
+ // Unknown XML constructs are evidence gaps, never silently discarded changes.
11
+ export function baselineFields(payload, artifact) {
12
+ if (!payload || /<!DOCTYPE|<!ENTITY/i.test(payload)) return null;
13
+ const table = artifact.table;
14
+ if (!/^[a-z][a-z0-9_]*$/.test(table || '')) return null;
15
+ const outer = payload.replace(/^\s*<\?xml[^?]*\?>\s*/, '').trim();
16
+ const match = outer.match(/^<record_update\s+table="([a-z0-9_]+)">\s*<([a-z0-9_]+)\s+action="INSERT_OR_UPDATE">([\s\S]*)<\/\2>\s*<\/record_update>$/);
17
+ if (!match || match[1] !== table || match[2] !== table) return null;
18
+ const fields = {};
19
+ let rest = match[3];
20
+ while (rest.trim()) {
21
+ const field = rest.match(/^\s*<([a-z0-9_]+)(?:\s+[^<>]*?)?(?:\s*\/>|>([\s\S]*?)<\/\1>)/);
22
+ if (!field || Object.hasOwn(fields, field[1])) return null;
23
+ let value = field[2] ?? '';
24
+ if (value.startsWith('<![CDATA[') && value.endsWith(']]>')) value = value.slice(9, -3);
25
+ else {
26
+ if (/[<>]/.test(value) || /&(?!lt;|gt;|quot;|apos;|amp;)/.test(value)) return null;
27
+ value = decode(value);
28
+ }
29
+ fields[field[1]] = value;
30
+ rest = rest.slice(field[0].length);
31
+ }
32
+ if (fields.sys_id !== artifact.sys_id) return null;
33
+ return fields;
34
+ }
35
+
36
+ export function analyzeImpact({ artifact, current, baseline, dependencies = [], unknown = [] }) {
37
+ const fields = baselineFields(baseline?.payload, artifact);
38
+ const gaps = [...unknown, 'Dynamic script references, transitive dependencies and runtime outcomes are unknown; no execution testing was performed.'];
39
+ const changes = [];
40
+ if (!fields) gaps.push('The exact base payload is unavailable or its XML shape/identity is unsupported.');
41
+ if (!current) gaps.push('The current artifact is unavailable.');
42
+ if (fields && current) {
43
+ for (const field of Object.keys(fields).sort()) {
44
+ if (!isImpactField(field)) continue;
45
+ if (!Object.hasOwn(current, field)) {
46
+ gaps.push(`Current field ${field} was not returned; its revert impact is unknown.`);
47
+ continue;
48
+ }
49
+ if (raw(current[field]) !== fields[field]) {
50
+ changes.push({ field, current: raw(current[field]), base: fields[field],
51
+ effect: field === 'script' ? 'Replace the executable script with the base script; runtime behavior is unverified.'
52
+ : field === 'active' ? `Set activation to ${fields[field]}; execution eligibility changes.`
53
+ : `Replace ${field} with the base value; behavior beyond this field change is unknown.` });
54
+ }
55
+ }
56
+ gaps.push('Fields absent from the base payload and provider-generated normalization are unknown.');
57
+ }
58
+ return { artifact, evidence: { baseline_version: baseline?.sys_id || null,
59
+ baseline_source: baseline?.source || null, baseline_source_table: baseline?.source_table || null },
60
+ direct_behavior: { status: fields && current ? 'field_comparison' : 'unknown',
61
+ summary: fields && current ? `${changes.length} observed field change(s) on return to base; this is not a runtime simulation.` : 'Unknown without both current artifact and a supported exact base payload.' },
62
+ changes, known_dependencies: dependencies, unknown: gaps,
63
+ recommendation: { action: 'retain', basis: changes.length
64
+ ? 'Preserve the customization pending validation of the observed changes and unknown dependencies.'
65
+ : 'Preserve the customization until the unknown runtime and dependency effects are validated.',
66
+ automatic: false } };
67
+ }
package/src/sdk.js CHANGED
@@ -4,6 +4,7 @@ import { gunzipSync } from 'node:zlib';
4
4
  import { errAuth, errAPI, errNetwork } from './errors.js';
5
5
  import { getStringField } from './helpers.js';
6
6
  import { getAction, getStepTypes, createAction, updateAction, testAction } from './processflow-actions.js';
7
+ import { readSkippedBaseline, revertSkippedUpdate } from './skipped-update-provider.js';
7
8
 
8
9
  const DEFAULT_TIMEOUT = 30000;
9
10
 
@@ -94,7 +95,8 @@ export class SDKClient {
94
95
  }
95
96
 
96
97
  async request(endpoint, opts = {}) {
97
- const { timeout = this.timeout, touchLastSeen = true, ...requestOptions } = opts;
98
+ const { timeout = this.timeout, readOnly = false, touchLastSeen, ...requestOptions } = opts;
99
+ if (readOnly && (requestOptions.method || 'GET').toUpperCase() !== 'GET') throw new Error('Read-only SDK requests require GET');
98
100
  const controller = new AbortController();
99
101
  const timer = setTimeout(() => controller.abort(), timeout);
100
102
 
@@ -110,6 +112,8 @@ export class SDKClient {
110
112
  if (this.domain) {
111
113
  req.headers.set('X-Now-Domain', this.domain);
112
114
  }
115
+ // readOnly guards the data request, not standard authentication bootstrap.
116
+ // OAuth refresh and session/GCK negotiation are not instance-record writes.
113
117
  await this._setAuth(req);
114
118
 
115
119
  const resp = await fetch(req);
@@ -125,7 +129,10 @@ export class SDKClient {
125
129
 
126
130
  // Stamp last_seen on successful API call
127
131
  try {
128
- if (touchLastSeen && this.authProvider && typeof this.authProvider.touchLastSeen === 'function') {
132
+ // readOnly suppresses the credential last-seen write unconditionally: a
133
+ // caller passing touchLastSeen cannot override it (GLOSSARY contract).
134
+ const shouldTouch = readOnly ? false : (touchLastSeen ?? true);
135
+ if (shouldTouch && this.authProvider && typeof this.authProvider.touchLastSeen === 'function') {
129
136
  this.authProvider.touchLastSeen(this.baseURL);
130
137
  }
131
138
  } catch { /* non-fatal */ }
@@ -197,6 +204,14 @@ export class SDKClient {
197
204
  }
198
205
  }
199
206
 
207
+ async getSkippedUpdateBaseline(sysID) {
208
+ return readSkippedBaseline(this, sysID);
209
+ }
210
+
211
+ async revertSkippedUpdate(sysID, modCount) {
212
+ return revertSkippedUpdate(this, sysID, modCount);
213
+ }
214
+
200
215
  async list(table, params = {}, opts = {}) {
201
216
  const query = new URLSearchParams(params).toString();
202
217
  const endpoint = `${this.baseURL}/api/now/table/${table}${query ? '?' + query : ''}`;
@@ -204,6 +219,22 @@ export class SDKClient {
204
219
  return result?.result || [];
205
220
  }
206
221
 
222
+ // Strict Table API seam. `list` stays best-effort for legacy callers, but a
223
+ // 2xx response without a result array is a malformed envelope, not an empty
224
+ // table. Capture paths use this so an unexpected body becomes an unavailable
225
+ // source instead of a complete-looking empty inventory.
226
+ async listStrict(table, params = {}, opts = {}) {
227
+ const query = new URLSearchParams(params).toString();
228
+ const endpoint = `${this.baseURL}/api/now/table/${table}${query ? '?' + query : ''}`;
229
+ const response = await this.request(endpoint, { method: 'GET', ...opts });
230
+ if (!response || typeof response !== 'object' || !Array.isArray(response.result)) {
231
+ const error = new Error(`Malformed table response for ${table}: expected a JSON envelope with a result array`);
232
+ error.code = 'malformed_response';
233
+ throw error;
234
+ }
235
+ return response.result;
236
+ }
237
+
207
238
  async get(table, sysID) {
208
239
  const endpoint = `${this.baseURL}/api/now/table/${table}/${sysID}`;
209
240
  const result = await this.request(endpoint, { method: 'GET' });
@@ -0,0 +1,45 @@
1
+ import { errUsage } from './errors.js';
2
+
3
+ function validateId(id) {
4
+ if (!/^[0-9a-fA-F]{32}$/.test(id || '')) throw errUsage('A literal 32-hex skipped finding sys_id is required.');
5
+ }
6
+
7
+ // Contract observed in RevertUpdateVersionAjax.revertToBaseSystem, PDI Australia.
8
+ // Keep read and write separate. No Table API state update can implement revert.
9
+ export async function readSkippedBaseline(sdk, id) {
10
+ validateId(id);
11
+ const output = await sdk.executeScript(`(function() {
12
+ var record = new GlideRecordSecure('sys_upgrade_history_log');
13
+ if (!record.get('${id}') || !record.canRead()) throw new Error('Finding unavailable');
14
+ var name = record.getValue('file_name');
15
+ var storeAppID = GlideappUpdateVersion.getStoreAppID(record);
16
+ var baseline = storeAppID != null
17
+ ? GlideappUpdateVersion.getVersion(name, storeAppID, 'sys_store_app', null)
18
+ : GlideappUpdateVersion.getVersion(name, record.upgrade_history, 'sys_upgrade_history', null);
19
+ if (!baseline || !baseline.isValidRecord()) throw new Error('No base system version found');
20
+ gs.print('JSN_SKIPPED_BASE:' + JSON.stringify({sys_id: baseline.getUniqueValue(),
21
+ payload: baseline.getValue('payload'), source: baseline.getValue('source'),
22
+ source_table: baseline.getValue('source_table')}));
23
+ })();`, 'global');
24
+ const match = output.match(/JSN_SKIPPED_BASE:(\{[^\r\n]*\})/);
25
+ if (!match) throw errUsage('Base version inspection returned no verifiable result.');
26
+ try { return JSON.parse(match[1]); } catch { throw errUsage('Base version inspection returned invalid JSON.'); }
27
+ }
28
+
29
+ export async function revertSkippedUpdate(sdk, id, modCount) {
30
+ validateId(id);
31
+ if (!/^\d+$/.test(modCount || '')) throw errUsage('Finding update count is required for safe revert.');
32
+ const output = await sdk.executeScript(`(function() {
33
+ if (!gs.hasRole('admin')) throw new Error('Admin role required');
34
+ var record = new GlideRecordSecure('sys_upgrade_history_log');
35
+ if (!record.get('${id}') || !record.canWrite()) throw new Error('Finding unavailable or not writable');
36
+ if (record.getValue('disposition') !== '4' ||
37
+ ['', 'not_reviewed'].indexOf(record.getValue('resolution_status') || '') < 0 ||
38
+ record.getValue('sys_mod_count') !== '${modCount}') throw new Error('Finding changed or is not eligible');
39
+ new RevertUpdateVersionAjax().revertToBaseSystem('${id}');
40
+ gs.print('JSN_SKIPPED_REVERT:dispatched');
41
+ })();`, 'global');
42
+ if (!output.includes('JSN_SKIPPED_REVERT:dispatched')) throw errUsage('Revert dispatch outcome is unknown; inspect the finding before retrying.');
43
+ // The processor returns no success boolean. Caller must verify disposition and
44
+ // resolution by GET; dispatch alone must never be reported as success.
45
+ }