Skip to content

Commit 5eba2c3

Browse files
fix: review comments
1 parent 9fafffb commit 5eba2c3

File tree

7 files changed

+972
-54
lines changed

7 files changed

+972
-54
lines changed

messages/clean.json

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,28 @@
11
{
2-
"commandDescription": "Deactivates and deletes Omnistudio records (Omniscript, Integration Procedure, Flexcard, Data Mapper) that contain special characters in their name fields, or are duplicates of deployed records. These records block enabling the Omnistudio Metadata API on the standard data model.",
2+
"commandDescription": "Deactivates and deletes Omnistudio records (Omniscript, Integration Procedure, Flexcard, Data Mapper) that have special characters in their name fields, or are existing records without a unique name. These records prevent enabling the Omnistudio Metadata API. Before running this command, ensure all Omnistudio components in your org are deployed to a testing sandbox. Verify the results before enabling the Omnistudio Metadata API in your Production org.",
33
"examples": ["sf omnistudio:migration:clean -u orguser@domain.com"],
44
"enableVerboseOutput": "Enable verbose output",
5-
"confirmDeletion": "By proceeding further, you hereby consent to deactivate and permanently delete all Omnistudio records with special characters in their name fields, and existing orphan records. Do you want to proceed? [y/n]",
5+
"sandboxWarning": "Before running this command, ensure all Omnistudio components existing in your org are deployed to a testing sandbox. Verify the results before enabling the Omnistudio Metadata API in your Production org.",
6+
"confirmDeletion": "By proceeding further, you hereby consent to deactivate and permanently delete all Omnistudio records with special characters in their name fields, and existing records without a unique name that prevent enabling the Omnistudio Metadata API. Do you want to proceed? [y/n]",
67
"operationCancelled": "Operation cancelled.",
7-
"deletionComplete": "Cleanup of Omnistudio records with special characters and existing orphan records is complete.",
8+
"deletionComplete": "Cleanup of Omnistudio records with special characters and existing records without a unique name is complete.",
89
"standardDataModelRequired": "This command is only applicable for orgs on the standard data model.",
910
"metadataApiAlreadyEnabled": "The Omnistudio Metadata API is already enabled. No cleanup required.",
10-
"errorRunningClean": "Clean process failed: %s",
11-
"noSpecialCharRecords": "No %s records found with special characters.",
12-
"foundSpecialCharRecordsToRemove": "Found %s %s record(s) with special characters to remove.",
13-
"deactivatingRecords": "Deactivating %s %s record(s)...",
14-
"deactivatedRecords": "Successfully deactivated %s %s record(s).",
15-
"deletingRecords": "Deleting %s %s record(s)...",
16-
"deletedRecords": "Successfully deleted %s %s record(s).",
11+
"errorRunningClean": "Clean process failed reason : %s",
12+
"specialCharCleanupSectionStart": "Scanning %s records for special characters in name fields. These records prevent enabling the Omnistudio Metadata API.",
13+
"noSpecialCharRecords": "No %s records with special characters found.",
14+
"foundSpecialCharRecordsToRemove": "Found %s %s records with special characters to remove.",
15+
"deactivatingRecords": "Deactivating %s %s records...",
16+
"deactivatedRecords": "Successfully deactivated %s %s records.",
17+
"deletingRecords": "Deleting %s %s records that prevent enabling the Omnistudio Metadata API...",
18+
"deletedRecords": "Successfully deleted %s %s records.",
1719
"deleteRecordsFailed": "We couldn't delete %s records. Some records may not have been deleted.",
1820
"errorRemovingSpecialCharRecords": "We couldn't remove %s records with special characters: %s",
19-
"noNullUniqueNameRecords": "No %s records found that are duplicates of deployed records.",
20-
"foundNullUniqueNameRecords": "Found %s %s record(s) that are duplicates of deployed records.",
21-
"errorCleaningNullUniqueNameRecords": "We couldn't clean %s duplicate record: %s",
22-
"nullUniqueNameCleanupComplete": "Cleanup of existing orphan records is complete."
21+
"nullUniqueNameCleanupSectionStart": "Scanning existing %s records without a deployment reference (UniqueName is not set). These records prevent enabling the Omnistudio Metadata API.",
22+
"noNullUniqueNameRecords": "No existing %s records without a unique name found.",
23+
"foundNullUniqueNameRecords": "Found %s existing %s records without a unique name that prevent enabling the Omnistudio Metadata API.",
24+
"skippingOrphanRecords": "Skipping %s: no deployed counterpart found. These records will not be deleted.",
25+
"deactivationFailed": "Could not deactivate %s record %s: %s",
26+
"deletionFailed": "Could not delete %s record %s: %s",
27+
"nullUniqueNameCleanupComplete": "Cleanup of existing records without a unique name is complete."
2328
}

src/commands/omnistudio/migration/clean.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export default class Clean extends SfCommand<CleanResult> {
8080
return { success: false };
8181
}
8282

83+
Logger.warn(messages.getMessage('sandboxWarning'));
8384
const confirmed = await askConfirmation(messages.getMessage('confirmDeletion'));
8485
if (!confirmed) {
8586
Logger.log(messages.getMessage('operationCancelled'));

src/utils/config/ExistingRecordCleanupService.ts

Lines changed: 53 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,26 @@ interface EntityConfig {
1515
configTable: string;
1616
entityName: string;
1717
objectName: string;
18+
// Additional SELECT fields beyond Id and IsActive (used to build a human-readable error label)
19+
selectFields: string;
1820
// Parses one DeveloperName into a deduplication key + version number (null = skip)
1921
parseDeveloperName: (developerName: string) => { mapKey: string; version: number } | null;
2022
// Builds the SOQL WHERE clause from a mapKey and a comma-separated versions string
2123
buildSoqlWhere: (mapKey: string, versionsStr: string) => string;
24+
// Builds a human-readable identifier for a record (used in error messages)
25+
buildLabel: (record: RecordRef) => string;
2226
}
2327

2428
interface RecordRef {
2529
Id: string;
2630
IsActive: boolean;
31+
// Identifying fields populated from selectFields — entity-specific, may be absent on other entities
32+
Type?: string;
33+
SubType?: string;
34+
Language?: string;
35+
Name?: string;
36+
AuthorName?: string;
37+
VersionNumber?: number;
2738
}
2839

2940
// ── Module-level helpers ──────────────────────────────────────────────────────
@@ -61,20 +72,31 @@ const ENTITY_CONFIGS: EntityConfig[] = [
6172
configTable: Constants.OmniScriptConfigTable,
6273
entityName: Constants.OmniScriptComponentName,
6374
objectName: Constants.OmniProcessObjectName,
75+
selectFields: 'Type, SubType, Language, VersionNumber',
6476
parseDeveloperName: parseOmniProcessDeveloperName,
6577
buildSoqlWhere: (mapKey, versionsStr): string => buildOmniProcessWhere(mapKey, versionsStr, false),
78+
buildLabel: (r): string =>
79+
`Type: ${r.Type ?? ''}, SubType: ${r.SubType ?? ''}, Language: ${r.Language ?? ''}, Version: ${
80+
r.VersionNumber ?? ''
81+
}`,
6682
},
6783
{
6884
configTable: Constants.OmniIntegrationProcConfigTable,
6985
entityName: Constants.IntegrationProcedureComponentName,
7086
objectName: Constants.OmniProcessObjectName,
87+
selectFields: 'Type, SubType, Language, VersionNumber',
7188
parseDeveloperName: parseOmniProcessDeveloperName,
7289
buildSoqlWhere: (mapKey, versionsStr): string => buildOmniProcessWhere(mapKey, versionsStr, true),
90+
buildLabel: (r): string =>
91+
`Type: ${r.Type ?? ''}, SubType: ${r.SubType ?? ''}, Language: ${r.Language ?? ''}, Version: ${
92+
r.VersionNumber ?? ''
93+
}`,
7394
},
7495
{
7596
configTable: Constants.OmniDataTransformConfigTable,
7697
entityName: Constants.DataMapperComponentName,
7798
objectName: Constants.OmniDataTransformObjectName,
99+
selectFields: 'Name, VersionNumber',
78100
parseDeveloperName: (developerName): { mapKey: string; version: number } | null => {
79101
const lastUnderscore = developerName.lastIndexOf('_');
80102
if (lastUnderscore <= 0 || lastUnderscore >= developerName.length - 1) return null;
@@ -84,11 +106,13 @@ const ENTITY_CONFIGS: EntityConfig[] = [
84106
},
85107
buildSoqlWhere: (mapKey, versionsStr): string =>
86108
`Name = '${escapeSoql(mapKey)}' AND VersionNumber IN (${versionsStr}) AND UniqueName = null`,
109+
buildLabel: (r): string => `Name: ${r.Name ?? ''}, Version: ${r.VersionNumber ?? ''}`,
87110
},
88111
{
89112
configTable: Constants.OmniUiCardConfigTable,
90113
entityName: Constants.FlexCardComponentName,
91114
objectName: Constants.OmniUiCardObjectName,
115+
selectFields: 'Name, AuthorName, VersionNumber',
92116
parseDeveloperName: (developerName): { mapKey: string; version: number } | null => {
93117
const parts = developerName.split('_');
94118
if (parts.length < 3) return null;
@@ -105,6 +129,8 @@ const ENTITY_CONFIGS: EntityConfig[] = [
105129
` AND VersionNumber IN (${versionsStr}) AND UniqueName = null`
106130
);
107131
},
132+
buildLabel: (r): string =>
133+
`Name: ${r.Name ?? ''}, AuthorName: ${r.AuthorName ?? ''}, Version: ${r.VersionNumber ?? ''}`,
108134
},
109135
];
110136

@@ -129,6 +155,8 @@ export class ExistingRecordCleanupService {
129155
// ── Generic per-entity pipeline ──────────────────────────────────────────
130156

131157
private async processEntity(config: EntityConfig): Promise<void> {
158+
Logger.log(this.messages.getMessage('nullUniqueNameCleanupSectionStart', [config.entityName]));
159+
132160
let offset = 0;
133161
let hasMore = true;
134162
while (hasMore) {
@@ -137,8 +165,8 @@ export class ExistingRecordCleanupService {
137165

138166
const versionMap = this.buildVersionMap(developerNames, config.parseDeveloperName);
139167
if (versionMap.size > 0) {
140-
const records = await this.queryRecords(config.objectName, versionMap, config.buildSoqlWhere);
141-
await this.deactivateAndDeleteRecords(config.objectName, config.entityName, records);
168+
const records = await this.queryRecords(config, versionMap);
169+
await this.deactivateAndDeleteRecords(config, records);
142170
}
143171

144172
offset += BATCH_SIZE;
@@ -169,16 +197,15 @@ export class ExistingRecordCleanupService {
169197
return versionMap;
170198
}
171199

172-
// Queries the main object for records with UniqueName = null matching each key
173-
private async queryRecords(
174-
objectName: string,
175-
versionMap: Map<string, Set<number>>,
176-
buildSoqlWhere: EntityConfig['buildSoqlWhere']
177-
): Promise<RecordRef[]> {
200+
// Queries the main object for records with UniqueName = null matching each key.
201+
// selectFields are included so buildLabel can produce human-readable error messages.
202+
private async queryRecords(config: EntityConfig, versionMap: Map<string, Set<number>>): Promise<RecordRef[]> {
178203
const records: RecordRef[] = [];
179204
for (const [mapKey, versions] of versionMap) {
180205
const versionsStr = Array.from(versions).join(', ');
181-
const soql = `SELECT Id, IsActive FROM ${objectName} WHERE ${buildSoqlWhere(mapKey, versionsStr)} LIMIT 100`;
206+
const soql =
207+
`SELECT Id, IsActive, ${config.selectFields} FROM ${config.objectName}` +
208+
` WHERE ${config.buildSoqlWhere(mapKey, versionsStr)}`;
182209
const result = await this.connection.query<RecordRef>(soql);
183210
records.push(...result.records);
184211
}
@@ -187,54 +214,55 @@ export class ExistingRecordCleanupService {
187214

188215
// ── Shared deactivate + delete ───────────────────────────────────────────
189216

190-
private async deactivateAndDeleteRecords(
191-
objectName: string,
192-
entityName: string,
193-
records: RecordRef[]
194-
): Promise<void> {
217+
private async deactivateAndDeleteRecords(config: EntityConfig, records: RecordRef[]): Promise<void> {
195218
if (records.length === 0) {
196-
Logger.log(this.messages.getMessage('noNullUniqueNameRecords', [entityName]));
219+
Logger.log(this.messages.getMessage('noNullUniqueNameRecords', [config.entityName]));
197220
return;
198221
}
199222

200-
Logger.log(this.messages.getMessage('foundNullUniqueNameRecords', [records.length, entityName]));
223+
Logger.log(this.messages.getMessage('foundNullUniqueNameRecords', [records.length, config.entityName]));
224+
225+
// Build label map once so both deactivation and deletion can report human-readable identifiers
226+
const idToLabel = new Map(records.map((r) => [r.Id, config.buildLabel(r)]));
201227

202228
const activeIds = records.filter((r) => r.IsActive).map((r) => r.Id);
203229
const failedDeactivateIds = new Set<string>();
204230

205231
if (activeIds.length > 0) {
206-
Logger.log(this.messages.getMessage('deactivatingRecords', [activeIds.length, entityName]));
232+
Logger.log(this.messages.getMessage('deactivatingRecords', [activeIds.length, config.entityName]));
207233
// Deactivate one at a time to avoid UNKNOWN_ERROR (matches existing migration pattern)
208234
for (const id of activeIds) {
209235
try {
210236
await NetUtils.request(
211237
this.connection,
212-
`sobjects/${objectName}/${id}`,
238+
`sobjects/${config.objectName}/${id}`,
213239
{ IsActive: false },
214240
RequestMethod.PATCH
215241
);
216-
} catch {
217-
// If deactivation fails, exclude from deletion (matches Apex error handling)
242+
} catch (error) {
243+
const label = idToLabel.get(id) ?? id;
244+
Logger.error(this.messages.getMessage('deactivationFailed', [config.entityName, label, String(error)]));
218245
failedDeactivateIds.add(id);
219246
}
220247
}
221248
Logger.log(
222-
this.messages.getMessage('deactivatedRecords', [activeIds.length - failedDeactivateIds.size, entityName])
249+
this.messages.getMessage('deactivatedRecords', [activeIds.length - failedDeactivateIds.size, config.entityName])
223250
);
224251
await this.sleep();
225252
}
226253

227254
const idsToDelete = records.map((r) => r.Id).filter((id) => !failedDeactivateIds.has(id));
228255

229-
Logger.log(this.messages.getMessage('deletingRecords', [idsToDelete.length, entityName]));
256+
Logger.log(this.messages.getMessage('deletingRecords', [idsToDelete.length, config.entityName]));
230257
for (const id of idsToDelete) {
231258
try {
232-
await this.connection.sobject(objectName).delete(id);
259+
await this.connection.sobject(config.objectName).delete(id);
233260
} catch (error) {
234-
Logger.error(this.messages.getMessage('errorCleaningNullUniqueNameRecords', [entityName, String(error)]));
261+
const label = idToLabel.get(id) ?? id;
262+
Logger.error(this.messages.getMessage('deletionFailed', [config.entityName, label, String(error)]));
235263
}
236264
}
237-
Logger.log(this.messages.getMessage('deletedRecords', [idsToDelete.length, entityName]));
265+
Logger.log(this.messages.getMessage('deletedRecords', [idsToDelete.length, config.entityName]));
238266
}
239267

240268
private sleep(): Promise<void> {

0 commit comments

Comments
 (0)