@memberjunction/metadata-sync 2.117.0 ā 2.119.0
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.
- package/README.md +24 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +12 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/database-reference-scanner.d.ts +56 -0
- package/dist/lib/database-reference-scanner.js +175 -0
- package/dist/lib/database-reference-scanner.js.map +1 -0
- package/dist/lib/deletion-auditor.d.ts +76 -0
- package/dist/lib/deletion-auditor.js +219 -0
- package/dist/lib/deletion-auditor.js.map +1 -0
- package/dist/lib/deletion-report-generator.d.ts +58 -0
- package/dist/lib/deletion-report-generator.js +287 -0
- package/dist/lib/deletion-report-generator.js.map +1 -0
- package/dist/lib/entity-foreign-key-helper.d.ts +51 -0
- package/dist/lib/entity-foreign-key-helper.js +83 -0
- package/dist/lib/entity-foreign-key-helper.js.map +1 -0
- package/dist/lib/provider-utils.d.ts +9 -1
- package/dist/lib/provider-utils.js +42 -5
- package/dist/lib/provider-utils.js.map +1 -1
- package/dist/lib/record-dependency-analyzer.d.ts +44 -0
- package/dist/lib/record-dependency-analyzer.js +133 -0
- package/dist/lib/record-dependency-analyzer.js.map +1 -1
- package/dist/services/PullService.d.ts +2 -0
- package/dist/services/PullService.js +4 -0
- package/dist/services/PullService.js.map +1 -1
- package/dist/services/PushService.d.ts +42 -2
- package/dist/services/PushService.js +451 -109
- package/dist/services/PushService.js.map +1 -1
- package/dist/services/StatusService.d.ts +2 -0
- package/dist/services/StatusService.js +5 -1
- package/dist/services/StatusService.js.map +1 -1
- package/dist/services/ValidationService.d.ts +4 -0
- package/dist/services/ValidationService.js +32 -2
- package/dist/services/ValidationService.js.map +1 -1
- package/dist/types/validation.d.ts +2 -0
- package/dist/types/validation.js.map +1 -1
- package/package.json +9 -8
|
@@ -16,6 +16,9 @@ const transaction_manager_1 = require("../lib/transaction-manager");
|
|
|
16
16
|
const json_write_helper_1 = require("../lib/json-write-helper");
|
|
17
17
|
const record_dependency_analyzer_1 = require("../lib/record-dependency-analyzer");
|
|
18
18
|
const json_preprocessor_1 = require("../lib/json-preprocessor");
|
|
19
|
+
const provider_utils_1 = require("../lib/provider-utils");
|
|
20
|
+
const deletion_auditor_1 = require("../lib/deletion-auditor");
|
|
21
|
+
const deletion_report_generator_1 = require("../lib/deletion-report-generator");
|
|
19
22
|
// Configuration for parallel processing
|
|
20
23
|
const PARALLEL_BATCH_SIZE = 1; // Number of records to process in parallel at each dependency level
|
|
21
24
|
class PushService {
|
|
@@ -23,12 +26,18 @@ class PushService {
|
|
|
23
26
|
contextUser;
|
|
24
27
|
warnings = [];
|
|
25
28
|
syncConfig;
|
|
29
|
+
deferredFileWrites = new Map();
|
|
26
30
|
constructor(syncEngine, contextUser) {
|
|
27
31
|
this.syncEngine = syncEngine;
|
|
28
32
|
this.contextUser = contextUser;
|
|
29
33
|
}
|
|
30
34
|
async push(options, callbacks) {
|
|
31
35
|
this.warnings = [];
|
|
36
|
+
// Validate that include and exclude are not used together
|
|
37
|
+
if (options.include && options.exclude) {
|
|
38
|
+
throw new Error('Cannot specify both --include and --exclude options. Please use one or the other.');
|
|
39
|
+
}
|
|
40
|
+
this.deferredFileWrites.clear(); // Reset deferred writes for this push operation
|
|
32
41
|
const fileBackupManager = new file_backup_manager_1.FileBackupManager();
|
|
33
42
|
// Load sync config for SQL logging settings and autoCreateMissingRecords flag
|
|
34
43
|
// If dir option is specified, load from that directory, otherwise use original CWD
|
|
@@ -97,7 +106,7 @@ class PushService {
|
|
|
97
106
|
// Find entity directories to process
|
|
98
107
|
// Note: If options.dir is specified, configDir already points to that directory
|
|
99
108
|
// So we don't need to pass it as specificDir
|
|
100
|
-
const entityDirs =
|
|
109
|
+
const entityDirs = (0, provider_utils_1.findEntityDirectories)(configDir, undefined, this.syncConfig?.directoryOrder, this.syncConfig?.ignoreDirectories, options.include, options.exclude);
|
|
101
110
|
if (entityDirs.length === 0) {
|
|
102
111
|
throw new Error('No entity directories found');
|
|
103
112
|
}
|
|
@@ -118,11 +127,55 @@ class PushService {
|
|
|
118
127
|
let totalDeleted = 0;
|
|
119
128
|
let totalSkipped = 0;
|
|
120
129
|
let totalErrors = 0;
|
|
130
|
+
// PHASE 0: Audit all deletions across all entities (if any exist)
|
|
131
|
+
let deletionAudit = null;
|
|
132
|
+
try {
|
|
133
|
+
deletionAudit = await this.auditAllDeletions(entityDirs, options, callbacks);
|
|
134
|
+
}
|
|
135
|
+
catch (auditError) {
|
|
136
|
+
// Audit failed, cannot proceed
|
|
137
|
+
throw auditError;
|
|
138
|
+
}
|
|
139
|
+
// CONFIRMATION PROMPT: Ask user to confirm only if deletions will occur
|
|
140
|
+
if (!options.dryRun && deletionAudit) {
|
|
141
|
+
const shouldProceed = await this.promptForConfirmation(deletionAudit, callbacks);
|
|
142
|
+
if (!shouldProceed) {
|
|
143
|
+
callbacks?.onLog?.('\nā Push operation cancelled by user.\n');
|
|
144
|
+
// Clean up SQL logging session and file if it was created
|
|
145
|
+
if (sqlLoggingSession) {
|
|
146
|
+
const sqlLogPath = sqlLoggingSession.filePath;
|
|
147
|
+
try {
|
|
148
|
+
await sqlLoggingSession.dispose();
|
|
149
|
+
// Delete the empty SQL log file since no operations occurred
|
|
150
|
+
if (await fs_extra_1.default.pathExists(sqlLogPath)) {
|
|
151
|
+
await fs_extra_1.default.remove(sqlLogPath);
|
|
152
|
+
if (options.verbose) {
|
|
153
|
+
callbacks?.onLog?.(`šļø Removed empty SQL log file: ${sqlLogPath}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
catch (cleanupError) {
|
|
158
|
+
callbacks?.onWarn?.(`Failed to clean up SQL logging session: ${cleanupError}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
created: 0,
|
|
163
|
+
updated: 0,
|
|
164
|
+
unchanged: 0,
|
|
165
|
+
deleted: 0,
|
|
166
|
+
skipped: 0,
|
|
167
|
+
errors: 0,
|
|
168
|
+
warnings: this.warnings
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
}
|
|
121
172
|
// Begin transaction if not in dry-run mode
|
|
122
173
|
if (!options.dryRun) {
|
|
123
174
|
await transactionManager.beginTransaction();
|
|
124
175
|
}
|
|
125
176
|
try {
|
|
177
|
+
// PHASE 1: Process creates/updates for all entities
|
|
178
|
+
callbacks?.onLog?.('š Processing creates and updates...\n');
|
|
126
179
|
for (const entityDir of entityDirs) {
|
|
127
180
|
const entityConfig = await (0, config_1.loadEntityConfig)(entityDir);
|
|
128
181
|
if (!entityConfig) {
|
|
@@ -180,10 +233,20 @@ class PushService {
|
|
|
180
233
|
totalSkipped += result.skipped;
|
|
181
234
|
totalErrors += result.errors;
|
|
182
235
|
}
|
|
236
|
+
// PHASE 2: Process deletions in reverse dependency order (if any exist)
|
|
237
|
+
if (deletionAudit && totalErrors === 0) {
|
|
238
|
+
const deletionResult = await this.processDeletionsFromAudit(deletionAudit, options, callbacks);
|
|
239
|
+
totalDeleted += deletionResult.deleted;
|
|
240
|
+
totalErrors += deletionResult.errors;
|
|
241
|
+
}
|
|
183
242
|
// Commit transaction if successful
|
|
184
243
|
if (!options.dryRun && totalErrors === 0) {
|
|
185
244
|
await transactionManager.commitTransaction();
|
|
186
245
|
}
|
|
246
|
+
// PHASE 3: Write deferred files with updated deletion timestamps
|
|
247
|
+
if (!options.dryRun && totalErrors === 0 && this.deferredFileWrites.size > 0) {
|
|
248
|
+
await this.writeDeferredFiles(options, callbacks);
|
|
249
|
+
}
|
|
187
250
|
}
|
|
188
251
|
catch (error) {
|
|
189
252
|
// Rollback transaction on error
|
|
@@ -338,7 +401,11 @@ class PushService {
|
|
|
338
401
|
}
|
|
339
402
|
// Update stats for successful results
|
|
340
403
|
const result = batchResult.result;
|
|
341
|
-
|
|
404
|
+
// Don't count deletion records - they're counted in Phase 2
|
|
405
|
+
if (result.isDeletedRecord) {
|
|
406
|
+
continue; // Skip entirely
|
|
407
|
+
}
|
|
408
|
+
else if (result.isDuplicate) {
|
|
342
409
|
skipped++; // Count duplicates as skipped
|
|
343
410
|
}
|
|
344
411
|
else {
|
|
@@ -365,22 +432,25 @@ class PushService {
|
|
|
365
432
|
try {
|
|
366
433
|
const result = await this.processFlattenedRecord(flattenedRecord, entityDir, options, batchContext, callbacks, entityConfig);
|
|
367
434
|
// Update stats
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
435
|
+
// Don't count deletion records - they're counted in Phase 2
|
|
436
|
+
if (!result.isDeletedRecord) {
|
|
437
|
+
if (result.isDuplicate) {
|
|
438
|
+
skipped++; // Count duplicates as skipped
|
|
439
|
+
}
|
|
440
|
+
else {
|
|
441
|
+
if (result.status === 'created')
|
|
442
|
+
created++;
|
|
443
|
+
else if (result.status === 'updated')
|
|
444
|
+
updated++;
|
|
445
|
+
else if (result.status === 'unchanged')
|
|
446
|
+
unchanged++;
|
|
447
|
+
else if (result.status === 'deleted')
|
|
448
|
+
deleted++;
|
|
449
|
+
else if (result.status === 'skipped')
|
|
450
|
+
skipped++;
|
|
451
|
+
else if (result.status === 'error')
|
|
452
|
+
errors++;
|
|
453
|
+
}
|
|
384
454
|
}
|
|
385
455
|
}
|
|
386
456
|
catch (recordError) {
|
|
@@ -390,14 +460,27 @@ class PushService {
|
|
|
390
460
|
}
|
|
391
461
|
}
|
|
392
462
|
}
|
|
463
|
+
// Check if this file has any deletion records (including nested relatedEntities)
|
|
464
|
+
const hasDeletions = this.hasAnyDeletions(records);
|
|
393
465
|
// Write back to file (handles both single records and arrays)
|
|
466
|
+
// Defer writing if file contains deletions - they'll be written after Phase 2
|
|
394
467
|
if (!options.dryRun) {
|
|
395
|
-
if (
|
|
396
|
-
|
|
468
|
+
if (hasDeletions) {
|
|
469
|
+
// Store for later writing after deletions complete
|
|
470
|
+
this.deferredFileWrites.set(filePath, {
|
|
471
|
+
filePath,
|
|
472
|
+
records,
|
|
473
|
+
isArray
|
|
474
|
+
});
|
|
397
475
|
}
|
|
398
476
|
else {
|
|
399
|
-
//
|
|
400
|
-
|
|
477
|
+
// Write immediately for files without deletions
|
|
478
|
+
if (isArray) {
|
|
479
|
+
await json_write_helper_1.JsonWriteHelper.writeOrderedRecordData(filePath, records);
|
|
480
|
+
}
|
|
481
|
+
else {
|
|
482
|
+
await json_write_helper_1.JsonWriteHelper.writeOrderedRecordData(filePath, records[0]);
|
|
483
|
+
}
|
|
401
484
|
}
|
|
402
485
|
}
|
|
403
486
|
}
|
|
@@ -411,9 +494,11 @@ class PushService {
|
|
|
411
494
|
async processFlattenedRecord(flattenedRecord, entityDir, options, batchContext, callbacks, entityConfig) {
|
|
412
495
|
const metadata = new core_1.Metadata();
|
|
413
496
|
const { record, entityName, parentContext, id: recordId } = flattenedRecord;
|
|
414
|
-
//
|
|
497
|
+
// Skip deletion records - they're handled in Phase 2
|
|
498
|
+
// File writing is deferred for files containing deletions
|
|
499
|
+
// Mark with special flag so they don't count in Phase 1 stats at all
|
|
415
500
|
if (record.deleteRecord && record.deleteRecord.delete === true) {
|
|
416
|
-
return
|
|
501
|
+
return { status: 'unchanged', isDuplicate: false, isDeletedRecord: true };
|
|
417
502
|
}
|
|
418
503
|
// Use the unique record ID from the flattened record for batch context
|
|
419
504
|
// This ensures we can properly find parent entities even when they're new
|
|
@@ -794,16 +879,24 @@ class PushService {
|
|
|
794
879
|
if (!record.primaryKey || Object.keys(record.primaryKey).length === 0) {
|
|
795
880
|
throw new Error(`Cannot delete ${entityName} record without primaryKey. Please specify primaryKey fields.`);
|
|
796
881
|
}
|
|
882
|
+
// Load the entity to check if it exists in the target database
|
|
883
|
+
const existingEntity = await this.syncEngine.loadEntity(entityName, record.primaryKey);
|
|
797
884
|
// Check if the deletion has already been processed
|
|
798
885
|
if (record.deleteRecord?.deletedAt) {
|
|
886
|
+
// Verify if record still exists in THIS database
|
|
887
|
+
if (!existingEntity) {
|
|
888
|
+
if (options.verbose) {
|
|
889
|
+
callbacks?.onLog?.(` ā¹ļø Record already deleted on ${record.deleteRecord.deletedAt} and confirmed absent from database`);
|
|
890
|
+
}
|
|
891
|
+
return { status: 'unchanged', isDuplicate: false };
|
|
892
|
+
}
|
|
893
|
+
// Record has deletedAt timestamp but still exists in this database
|
|
894
|
+
// This can happen when syncing to a different database
|
|
799
895
|
if (options.verbose) {
|
|
800
|
-
callbacks?.onLog?.(` ā¹ļø Record
|
|
896
|
+
callbacks?.onLog?.(` ā¹ļø Record marked as deleted on ${record.deleteRecord.deletedAt}, but still exists in this database. Re-deleting...`);
|
|
801
897
|
}
|
|
802
|
-
//
|
|
803
|
-
return { status: 'unchanged', isDuplicate: false };
|
|
898
|
+
// Fall through to deletion logic
|
|
804
899
|
}
|
|
805
|
-
// Load the entity to verify it exists
|
|
806
|
-
const existingEntity = await this.syncEngine.loadEntity(entityName, record.primaryKey);
|
|
807
900
|
if (!existingEntity) {
|
|
808
901
|
const pkDisplay = Object.entries(record.primaryKey)
|
|
809
902
|
.map(([key, value]) => `${key}=${value}`)
|
|
@@ -855,15 +948,13 @@ class PushService {
|
|
|
855
948
|
}
|
|
856
949
|
throw new Error(`Failed to delete ${entityName} record: ${errorMessage}`);
|
|
857
950
|
}
|
|
858
|
-
//
|
|
951
|
+
// Set deletedAt timestamp after successful deletion
|
|
859
952
|
if (!record.deleteRecord) {
|
|
860
953
|
record.deleteRecord = { delete: true };
|
|
861
954
|
}
|
|
862
955
|
record.deleteRecord.deletedAt = new Date().toISOString();
|
|
863
|
-
//
|
|
864
|
-
|
|
865
|
-
delete record.deleteRecord.notFound;
|
|
866
|
-
}
|
|
956
|
+
// Update the corresponding record in deferred file writes
|
|
957
|
+
this.updateDeferredFileRecord(flattenedRecord);
|
|
867
958
|
if (options.verbose) {
|
|
868
959
|
callbacks?.onLog?.(` ā Successfully deleted ${entityName} record`);
|
|
869
960
|
}
|
|
@@ -884,102 +975,353 @@ class PushService {
|
|
|
884
975
|
throw deleteError;
|
|
885
976
|
}
|
|
886
977
|
}
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
978
|
+
/**
|
|
979
|
+
* Prompt user for confirmation before proceeding with push operation
|
|
980
|
+
* This happens after deletion audit but before any database operations
|
|
981
|
+
*/
|
|
982
|
+
async promptForConfirmation(deletionAudit, callbacks) {
|
|
983
|
+
// Build confirmation message
|
|
984
|
+
const messages = [];
|
|
985
|
+
messages.push('\n' + 'ā'.repeat(80));
|
|
986
|
+
messages.push('CONFIRMATION REQUIRED');
|
|
987
|
+
messages.push('ā'.repeat(80));
|
|
988
|
+
messages.push('');
|
|
989
|
+
messages.push('This operation will:');
|
|
990
|
+
messages.push(' ⢠Create new records');
|
|
991
|
+
messages.push(' ⢠Update existing records');
|
|
992
|
+
if (deletionAudit) {
|
|
993
|
+
const totalDeletes = deletionAudit.explicitDeletes.size + deletionAudit.implicitDeletes.size;
|
|
994
|
+
messages.push(` ⢠Delete ${totalDeletes} record${totalDeletes > 1 ? 's' : ''} (${deletionAudit.explicitDeletes.size} explicit, ${deletionAudit.implicitDeletes.size} implicit)`);
|
|
995
|
+
if (deletionAudit.orphanedReferences.length > 0) {
|
|
996
|
+
messages.push(` ā ļø ${deletionAudit.orphanedReferences.length} database-only reference${deletionAudit.orphanedReferences.length > 1 ? 's' : ''} detected (may cause FK errors)`);
|
|
894
997
|
}
|
|
895
998
|
}
|
|
896
999
|
else {
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
1000
|
+
messages.push(' ⢠No deletions');
|
|
1001
|
+
}
|
|
1002
|
+
messages.push('');
|
|
1003
|
+
messages.push('All operations will occur within a transaction and can be rolled back on error.');
|
|
1004
|
+
messages.push('');
|
|
1005
|
+
messages.push('ā'.repeat(80));
|
|
1006
|
+
messages.push('');
|
|
1007
|
+
// Display messages
|
|
1008
|
+
for (const msg of messages) {
|
|
1009
|
+
callbacks?.onLog?.(msg);
|
|
1010
|
+
}
|
|
1011
|
+
// Use onConfirm callback if available, otherwise throw error
|
|
1012
|
+
if (callbacks?.onConfirm) {
|
|
1013
|
+
const confirmed = await callbacks.onConfirm('Do you want to proceed? (yes/no)');
|
|
1014
|
+
return confirmed;
|
|
1015
|
+
}
|
|
1016
|
+
else {
|
|
1017
|
+
// No confirmation callback provided - this shouldn't happen in interactive mode
|
|
1018
|
+
callbacks?.onWarn?.('ā ļø No confirmation callback provided. Proceeding automatically.');
|
|
1019
|
+
callbacks?.onWarn?.(' To enable confirmation prompts, provide an onConfirm callback.\n');
|
|
1020
|
+
return true;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Audit all deletions across all metadata files
|
|
1025
|
+
* This pre-processes all records to identify deletion dependencies and order
|
|
1026
|
+
*/
|
|
1027
|
+
async auditAllDeletions(entityDirs, options, callbacks) {
|
|
1028
|
+
// OPTIMIZATION: Quick scan to check if ANY deletions exist before doing expensive loading
|
|
1029
|
+
let hasAnyDeletions = false;
|
|
1030
|
+
for (const entityDir of entityDirs) {
|
|
1031
|
+
if (hasAnyDeletions)
|
|
1032
|
+
break; // Early exit once we find any deletion
|
|
1033
|
+
const entityConfig = await (0, config_1.loadEntityConfig)(entityDir);
|
|
1034
|
+
if (!entityConfig) {
|
|
1035
|
+
continue;
|
|
1036
|
+
}
|
|
1037
|
+
const pattern = entityConfig.filePattern || '*.json';
|
|
1038
|
+
const files = await (0, fast_glob_1.default)(pattern, {
|
|
1039
|
+
cwd: entityDir,
|
|
1040
|
+
absolute: true,
|
|
1041
|
+
onlyFiles: true,
|
|
1042
|
+
dot: true,
|
|
1043
|
+
ignore: ['**/node_modules/**', '**/.mj-*.json']
|
|
1044
|
+
});
|
|
1045
|
+
// Quick scan for delete directives without full processing
|
|
1046
|
+
for (const filePath of files) {
|
|
1047
|
+
try {
|
|
1048
|
+
const content = await fs_extra_1.default.readFile(filePath, 'utf-8');
|
|
1049
|
+
// Fast string check for delete directives
|
|
1050
|
+
if (content.includes('"delete"') && content.includes('true')) {
|
|
1051
|
+
// More precise check - parse JSON to confirm
|
|
1052
|
+
const data = JSON.parse(content);
|
|
1053
|
+
const records = Array.isArray(data) ? data : [data];
|
|
1054
|
+
const hasDelete = records.some((r) => r.deleteRecord?.delete === true);
|
|
1055
|
+
if (hasDelete) {
|
|
1056
|
+
hasAnyDeletions = true;
|
|
1057
|
+
break;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
catch (error) {
|
|
1062
|
+
// Ignore errors in quick scan
|
|
902
1063
|
}
|
|
903
1064
|
}
|
|
904
1065
|
}
|
|
905
|
-
|
|
1066
|
+
// If no deletions found, skip all processing
|
|
1067
|
+
if (!hasAnyDeletions) {
|
|
1068
|
+
if (options.verbose) {
|
|
1069
|
+
callbacks?.onLog?.('No deletion operations found - skipping deletion audit.\n');
|
|
1070
|
+
}
|
|
1071
|
+
return null;
|
|
1072
|
+
}
|
|
1073
|
+
// Deletions exist - proceed with full audit
|
|
1074
|
+
callbacks?.onLog?.('\nš Analyzing deletion operations...\n');
|
|
1075
|
+
// Load all records from all entity directories
|
|
1076
|
+
const allRecords = [];
|
|
1077
|
+
const analyzer = new record_dependency_analyzer_1.RecordDependencyAnalyzer();
|
|
1078
|
+
for (const entityDir of entityDirs) {
|
|
1079
|
+
const entityConfig = await (0, config_1.loadEntityConfig)(entityDir);
|
|
1080
|
+
if (!entityConfig) {
|
|
1081
|
+
continue;
|
|
1082
|
+
}
|
|
1083
|
+
// Find all JSON files
|
|
1084
|
+
const pattern = entityConfig.filePattern || '*.json';
|
|
1085
|
+
const files = await (0, fast_glob_1.default)(pattern, {
|
|
1086
|
+
cwd: entityDir,
|
|
1087
|
+
absolute: true,
|
|
1088
|
+
onlyFiles: true,
|
|
1089
|
+
dot: true,
|
|
1090
|
+
ignore: ['**/node_modules/**', '**/.mj-*.json']
|
|
1091
|
+
});
|
|
1092
|
+
// Load and flatten records from each file
|
|
1093
|
+
for (const filePath of files) {
|
|
1094
|
+
try {
|
|
1095
|
+
const rawFileData = await fs_extra_1.default.readJson(filePath);
|
|
1096
|
+
// Handle @include directives if present
|
|
1097
|
+
let fileData = rawFileData;
|
|
1098
|
+
const jsonString = JSON.stringify(rawFileData);
|
|
1099
|
+
const hasIncludes = jsonString.includes('"@include"') || jsonString.includes('"@include.');
|
|
1100
|
+
if (hasIncludes) {
|
|
1101
|
+
const jsonPreprocessor = new json_preprocessor_1.JsonPreprocessor();
|
|
1102
|
+
fileData = await jsonPreprocessor.processFile(filePath);
|
|
1103
|
+
}
|
|
1104
|
+
const records = Array.isArray(fileData) ? fileData : [fileData];
|
|
1105
|
+
// Analyze and flatten records
|
|
1106
|
+
const analysisResult = await analyzer.analyzeFileRecords(records, entityConfig.entity);
|
|
1107
|
+
allRecords.push(...analysisResult.sortedRecords);
|
|
1108
|
+
}
|
|
1109
|
+
catch (error) {
|
|
1110
|
+
if (options.verbose) {
|
|
1111
|
+
callbacks?.onLog?.(`Warning: Could not load ${filePath}: ${error}`);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
// Perform comprehensive deletion audit
|
|
1117
|
+
const md = new core_1.Metadata();
|
|
1118
|
+
const auditor = new deletion_auditor_1.DeletionAuditor(md, this.contextUser);
|
|
1119
|
+
const audit = await auditor.auditDeletions(allRecords);
|
|
1120
|
+
// Check if any records actually need deletion
|
|
1121
|
+
const totalMarkedForDeletion = audit.explicitDeletes.size + audit.implicitDeletes.size;
|
|
1122
|
+
const needDeletion = audit.deletionLevels.flat().length; // Only records that exist in DB
|
|
1123
|
+
if (needDeletion === 0) {
|
|
1124
|
+
// All records marked for deletion are already deleted
|
|
1125
|
+
if (options.verbose && totalMarkedForDeletion > 0) {
|
|
1126
|
+
callbacks?.onLog?.(`ā¹ļø All ${totalMarkedForDeletion} record${totalMarkedForDeletion > 1 ? 's' : ''} marked for deletion are already deleted from the database.`);
|
|
1127
|
+
callbacks?.onLog?.(' No deletion operations needed.\n');
|
|
1128
|
+
}
|
|
1129
|
+
return null; // Signal that no deletion audit is needed
|
|
1130
|
+
}
|
|
1131
|
+
// Generate and display report (only if records need deletion)
|
|
1132
|
+
const report = deletion_report_generator_1.DeletionReportGenerator.generateReport(audit, options.verbose);
|
|
1133
|
+
callbacks?.onLog?.(report);
|
|
1134
|
+
callbacks?.onLog?.('');
|
|
1135
|
+
// Check for blocking issues (only circular dependencies block execution)
|
|
1136
|
+
if (audit.circularDependencies.length > 0) {
|
|
1137
|
+
const error = `Cannot proceed: ${audit.circularDependencies.length} circular ${audit.circularDependencies.length > 1 ? 'dependencies' : 'dependency'} detected.\n` +
|
|
1138
|
+
`Please resolve the circular dependencies before attempting deletion.`;
|
|
1139
|
+
callbacks?.onError?.(error);
|
|
1140
|
+
throw new Error(error);
|
|
1141
|
+
}
|
|
1142
|
+
// Warn about database-only references (non-blocking)
|
|
1143
|
+
// These may be handled by cascade delete rules at the database level
|
|
1144
|
+
if (audit.orphanedReferences.length > 0) {
|
|
1145
|
+
callbacks?.onWarn?.(`ā ļø WARNING: ${audit.orphanedReferences.length} database-only reference${audit.orphanedReferences.length > 1 ? 's' : ''} found.`);
|
|
1146
|
+
callbacks?.onWarn?.(` These records exist in the database but not in metadata.`);
|
|
1147
|
+
callbacks?.onWarn?.(` If your database has cascade delete rules, these will be handled automatically.`);
|
|
1148
|
+
callbacks?.onWarn?.(` Otherwise, deletion may fail with FK constraint errors.\n`);
|
|
1149
|
+
}
|
|
1150
|
+
// Warn about implicit deletes
|
|
1151
|
+
if (audit.implicitDeletes.size > 0) {
|
|
1152
|
+
callbacks?.onWarn?.('ā ļø WARNING: Implicit deletions will occur.');
|
|
1153
|
+
callbacks?.onWarn?.(` ${audit.implicitDeletes.size} record${audit.implicitDeletes.size > 1 ? 's' : ''} will be deleted due to FK constraints.`);
|
|
1154
|
+
callbacks?.onWarn?.(' Review the deletion audit report above.\n');
|
|
1155
|
+
}
|
|
1156
|
+
return audit;
|
|
906
1157
|
}
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
}
|
|
1158
|
+
/**
|
|
1159
|
+
* Process deletions in reverse dependency order
|
|
1160
|
+
*/
|
|
1161
|
+
async processDeletionsFromAudit(audit, options, callbacks) {
|
|
1162
|
+
let deleted = 0;
|
|
1163
|
+
let errors = 0;
|
|
1164
|
+
callbacks?.onLog?.('šļø Processing deletions in reverse dependency order...\n');
|
|
1165
|
+
// Process deletion levels in order (highest dependency level first)
|
|
1166
|
+
for (let i = 0; i < audit.deletionLevels.length; i++) {
|
|
1167
|
+
const level = audit.deletionLevels[i];
|
|
1168
|
+
const levelNumber = audit.deletionLevels.length - i; // Reverse numbering for clarity
|
|
1169
|
+
callbacks?.onLog?.(` Level ${levelNumber}: Deleting ${level.length} record${level.length > 1 ? 's' : ''}...`);
|
|
1170
|
+
// Process records within same level (can be done in parallel in the future)
|
|
1171
|
+
for (const record of level) {
|
|
1172
|
+
try {
|
|
1173
|
+
const result = await this.processDeleteRecord(record, '', options, callbacks);
|
|
1174
|
+
if (result.status === 'deleted') {
|
|
1175
|
+
deleted++;
|
|
926
1176
|
}
|
|
927
|
-
|
|
928
|
-
//
|
|
1177
|
+
else if (result.status === 'skipped') {
|
|
1178
|
+
// Record not found, already handled in processDeleteRecord
|
|
929
1179
|
}
|
|
930
1180
|
}
|
|
1181
|
+
catch (error) {
|
|
1182
|
+
callbacks?.onError?.(` Failed to delete ${record.entityName}: ${error}`);
|
|
1183
|
+
errors++;
|
|
1184
|
+
throw error; // Fail fast on first deletion error
|
|
1185
|
+
}
|
|
931
1186
|
}
|
|
932
1187
|
}
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
this.findEntityDirectoriesRecursive(baseDir, dirs);
|
|
936
|
-
}
|
|
937
|
-
// Apply directory ordering if specified
|
|
938
|
-
if (directoryOrder && directoryOrder.length > 0 && !specificDir) {
|
|
939
|
-
// Create a map of directory name to order index
|
|
940
|
-
const orderMap = new Map();
|
|
941
|
-
directoryOrder.forEach((dir, index) => {
|
|
942
|
-
orderMap.set(dir, index);
|
|
943
|
-
});
|
|
944
|
-
// Sort directories based on the order map
|
|
945
|
-
dirs.sort((a, b) => {
|
|
946
|
-
const nameA = path_1.default.basename(a);
|
|
947
|
-
const nameB = path_1.default.basename(b);
|
|
948
|
-
const orderA = orderMap.get(nameA) ?? Number.MAX_SAFE_INTEGER;
|
|
949
|
-
const orderB = orderMap.get(nameB) ?? Number.MAX_SAFE_INTEGER;
|
|
950
|
-
// If both have specified orders, use them
|
|
951
|
-
if (orderA !== Number.MAX_SAFE_INTEGER || orderB !== Number.MAX_SAFE_INTEGER) {
|
|
952
|
-
return orderA - orderB;
|
|
953
|
-
}
|
|
954
|
-
// Otherwise, maintain original order (stable sort)
|
|
955
|
-
return 0;
|
|
956
|
-
});
|
|
1188
|
+
if (deleted > 0) {
|
|
1189
|
+
callbacks?.onLog?.(` ā Successfully deleted ${deleted} record${deleted > 1 ? 's' : ''}\n`);
|
|
957
1190
|
}
|
|
958
|
-
return
|
|
1191
|
+
return { deleted, errors };
|
|
959
1192
|
}
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
1193
|
+
/**
|
|
1194
|
+
* Recursively check if any records in an array (including nested relatedEntities) have deletions
|
|
1195
|
+
*/
|
|
1196
|
+
hasAnyDeletions(records) {
|
|
1197
|
+
for (const record of records) {
|
|
1198
|
+
// Check this record
|
|
1199
|
+
if (record.deleteRecord?.delete === true) {
|
|
1200
|
+
return true;
|
|
1201
|
+
}
|
|
1202
|
+
// Check nested related entities recursively
|
|
1203
|
+
if (record.relatedEntities) {
|
|
1204
|
+
for (const relatedRecords of Object.values(record.relatedEntities)) {
|
|
1205
|
+
if (Array.isArray(relatedRecords)) {
|
|
1206
|
+
if (this.hasAnyDeletions(relatedRecords)) {
|
|
1207
|
+
return true;
|
|
971
1208
|
}
|
|
972
1209
|
}
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
return false;
|
|
1214
|
+
}
|
|
1215
|
+
/**
|
|
1216
|
+
* Write all deferred files with updated deletion timestamps
|
|
1217
|
+
* Called in Phase 3 after all deletions complete successfully
|
|
1218
|
+
*/
|
|
1219
|
+
async writeDeferredFiles(options, callbacks) {
|
|
1220
|
+
if (this.deferredFileWrites.size === 0) {
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1223
|
+
if (options.verbose) {
|
|
1224
|
+
callbacks?.onLog?.(`\nš Writing ${this.deferredFileWrites.size} file${this.deferredFileWrites.size > 1 ? 's' : ''} with deletion timestamps...`);
|
|
1225
|
+
}
|
|
1226
|
+
for (const deferredWrite of this.deferredFileWrites.values()) {
|
|
1227
|
+
try {
|
|
1228
|
+
if (deferredWrite.isArray) {
|
|
1229
|
+
await json_write_helper_1.JsonWriteHelper.writeOrderedRecordData(deferredWrite.filePath, deferredWrite.records);
|
|
976
1230
|
}
|
|
977
1231
|
else {
|
|
978
|
-
|
|
979
|
-
|
|
1232
|
+
await json_write_helper_1.JsonWriteHelper.writeOrderedRecordData(deferredWrite.filePath, deferredWrite.records[0]);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
catch (error) {
|
|
1236
|
+
callbacks?.onWarn?.(` ā ļø Failed to write ${deferredWrite.filePath}: ${error}`);
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
if (options.verbose) {
|
|
1240
|
+
callbacks?.onLog?.(` ā Completed writing deferred files\n`);
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
/**
|
|
1244
|
+
* Update a record in deferred file writes after successful deletion
|
|
1245
|
+
* Finds the matching RecordData by primary key and updates its deleteRecord section
|
|
1246
|
+
* Searches recursively through nested relatedEntities
|
|
1247
|
+
*/
|
|
1248
|
+
updateDeferredFileRecord(flattenedRecord) {
|
|
1249
|
+
const { record } = flattenedRecord;
|
|
1250
|
+
// Search through all deferred files to find the matching record
|
|
1251
|
+
for (const deferredWrite of this.deferredFileWrites.values()) {
|
|
1252
|
+
for (const fileRecord of deferredWrite.records) {
|
|
1253
|
+
// Search this record and all nested related entities recursively
|
|
1254
|
+
if (this.findAndUpdateRecord(fileRecord, record, flattenedRecord.entityName)) {
|
|
1255
|
+
return; // Found and updated
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
/**
|
|
1261
|
+
* Recursively search a RecordData and its relatedEntities for a matching record
|
|
1262
|
+
* Updates the matching record's deleteRecord timestamp
|
|
1263
|
+
*/
|
|
1264
|
+
findAndUpdateRecord(searchIn, targetRecord, targetEntityName) {
|
|
1265
|
+
// Check if this is the matching record
|
|
1266
|
+
if (this.recordsMatch(searchIn, targetRecord, targetEntityName)) {
|
|
1267
|
+
// Update the deleteRecord section with the timestamp
|
|
1268
|
+
if (!searchIn.deleteRecord) {
|
|
1269
|
+
searchIn.deleteRecord = { delete: true };
|
|
1270
|
+
}
|
|
1271
|
+
searchIn.deleteRecord.deletedAt = targetRecord.deleteRecord.deletedAt;
|
|
1272
|
+
return true; // Found and updated
|
|
1273
|
+
}
|
|
1274
|
+
// Search through related entities recursively
|
|
1275
|
+
if (searchIn.relatedEntities) {
|
|
1276
|
+
for (const relatedRecords of Object.values(searchIn.relatedEntities)) {
|
|
1277
|
+
if (Array.isArray(relatedRecords)) {
|
|
1278
|
+
for (const relatedRecord of relatedRecords) {
|
|
1279
|
+
if (this.findAndUpdateRecord(relatedRecord, targetRecord, targetEntityName)) {
|
|
1280
|
+
return true; // Found in nested record
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
return false; // Not found in this branch
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* Check if two RecordData objects represent the same record
|
|
1290
|
+
* Compares primary keys and entity context
|
|
1291
|
+
*/
|
|
1292
|
+
recordsMatch(record1, record2, entityName) {
|
|
1293
|
+
// Must both have primary keys
|
|
1294
|
+
if (!record1.primaryKey || !record2.primaryKey) {
|
|
1295
|
+
return false;
|
|
1296
|
+
}
|
|
1297
|
+
// Must have same primary key fields
|
|
1298
|
+
const pk1Keys = Object.keys(record1.primaryKey);
|
|
1299
|
+
const pk2Keys = Object.keys(record2.primaryKey);
|
|
1300
|
+
if (pk1Keys.length !== pk2Keys.length) {
|
|
1301
|
+
return false;
|
|
1302
|
+
}
|
|
1303
|
+
// All primary key values must match
|
|
1304
|
+
return pk1Keys.every(key => record1.primaryKey[key] === record2.primaryKey[key]);
|
|
1305
|
+
}
|
|
1306
|
+
_buildBatchContextKey(entityName, record) {
|
|
1307
|
+
// Build a unique key for the batch context based on entity name and identifying fields
|
|
1308
|
+
const keyParts = [entityName];
|
|
1309
|
+
// Use primary key if available
|
|
1310
|
+
if (record.primaryKey) {
|
|
1311
|
+
for (const [field, value] of Object.entries(record.primaryKey)) {
|
|
1312
|
+
keyParts.push(`${field}=${value}`);
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
else {
|
|
1316
|
+
// Use a combination of important fields as fallback
|
|
1317
|
+
const identifyingFields = ['Name', 'ID', 'Code', 'Email'];
|
|
1318
|
+
for (const field of identifyingFields) {
|
|
1319
|
+
if (record.fields[field]) {
|
|
1320
|
+
keyParts.push(`${field}=${record.fields[field]}`);
|
|
980
1321
|
}
|
|
981
1322
|
}
|
|
982
1323
|
}
|
|
1324
|
+
return keyParts.join('|');
|
|
983
1325
|
}
|
|
984
1326
|
}
|
|
985
1327
|
exports.PushService = PushService;
|