@memberjunction/metadata-sync 2.117.0 → 2.118.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.
Files changed (37) hide show
  1. package/README.md +24 -0
  2. package/dist/index.d.ts +9 -0
  3. package/dist/index.js +12 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/database-reference-scanner.d.ts +56 -0
  6. package/dist/lib/database-reference-scanner.js +175 -0
  7. package/dist/lib/database-reference-scanner.js.map +1 -0
  8. package/dist/lib/deletion-auditor.d.ts +76 -0
  9. package/dist/lib/deletion-auditor.js +219 -0
  10. package/dist/lib/deletion-auditor.js.map +1 -0
  11. package/dist/lib/deletion-report-generator.d.ts +58 -0
  12. package/dist/lib/deletion-report-generator.js +287 -0
  13. package/dist/lib/deletion-report-generator.js.map +1 -0
  14. package/dist/lib/entity-foreign-key-helper.d.ts +51 -0
  15. package/dist/lib/entity-foreign-key-helper.js +83 -0
  16. package/dist/lib/entity-foreign-key-helper.js.map +1 -0
  17. package/dist/lib/provider-utils.d.ts +9 -1
  18. package/dist/lib/provider-utils.js +42 -5
  19. package/dist/lib/provider-utils.js.map +1 -1
  20. package/dist/lib/record-dependency-analyzer.d.ts +44 -0
  21. package/dist/lib/record-dependency-analyzer.js +133 -0
  22. package/dist/lib/record-dependency-analyzer.js.map +1 -1
  23. package/dist/services/PullService.d.ts +2 -0
  24. package/dist/services/PullService.js +4 -0
  25. package/dist/services/PullService.js.map +1 -1
  26. package/dist/services/PushService.d.ts +42 -2
  27. package/dist/services/PushService.js +451 -109
  28. package/dist/services/PushService.js.map +1 -1
  29. package/dist/services/StatusService.d.ts +2 -0
  30. package/dist/services/StatusService.js +5 -1
  31. package/dist/services/StatusService.js.map +1 -1
  32. package/dist/services/ValidationService.d.ts +4 -0
  33. package/dist/services/ValidationService.js +32 -2
  34. package/dist/services/ValidationService.js.map +1 -1
  35. package/dist/types/validation.d.ts +2 -0
  36. package/dist/types/validation.js.map +1 -1
  37. 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 = this.findEntityDirectories(configDir, undefined, this.syncConfig?.directoryOrder);
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
- if (result.isDuplicate) {
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
- if (result.isDuplicate) {
369
- skipped++; // Count duplicates as skipped
370
- }
371
- else {
372
- if (result.status === 'created')
373
- created++;
374
- else if (result.status === 'updated')
375
- updated++;
376
- else if (result.status === 'unchanged')
377
- unchanged++;
378
- else if (result.status === 'deleted')
379
- deleted++;
380
- else if (result.status === 'skipped')
381
- skipped++;
382
- else if (result.status === 'error')
383
- errors++;
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 (isArray) {
396
- await json_write_helper_1.JsonWriteHelper.writeOrderedRecordData(filePath, records);
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
- // For single record files, write back the single record
400
- await json_write_helper_1.JsonWriteHelper.writeOrderedRecordData(filePath, records[0]);
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
- // Check if this record has a deleteRecord directive
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 await this.processDeleteRecord(flattenedRecord, entityDir, options, callbacks);
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 already deleted on ${record.deleteRecord.deletedAt}`);
896
+ callbacks?.onLog?.(` ā„¹ļø Record marked as deleted on ${record.deleteRecord.deletedAt}, but still exists in this database. Re-deleting...`);
801
897
  }
802
- // Return unchanged since the record is already in the desired state (deleted)
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
- // Update the deleteRecord section with deletedAt timestamp
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
- // Remove notFound flag if it exists since we successfully found and deleted the record
864
- if (record.deleteRecord.notFound) {
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
- _buildBatchContextKey(entityName, record) {
888
- // Build a unique key for the batch context based on entity name and identifying fields
889
- const keyParts = [entityName];
890
- // Use primary key if available
891
- if (record.primaryKey) {
892
- for (const [field, value] of Object.entries(record.primaryKey)) {
893
- keyParts.push(`${field}=${value}`);
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
- // Use a combination of important fields as fallback
898
- const identifyingFields = ['Name', 'ID', 'Code', 'Email'];
899
- for (const field of identifyingFields) {
900
- if (record.fields[field]) {
901
- keyParts.push(`${field}=${record.fields[field]}`);
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
- return keyParts.join('|');
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
- findEntityDirectories(baseDir, specificDir, directoryOrder) {
908
- const dirs = [];
909
- if (specificDir) {
910
- // Process specific directory
911
- const fullPath = path_1.default.resolve(baseDir, specificDir);
912
- if (fs_extra_1.default.existsSync(fullPath) && fs_extra_1.default.statSync(fullPath).isDirectory()) {
913
- // Check if this directory has an entity configuration
914
- const configPath = path_1.default.join(fullPath, '.mj-sync.json');
915
- if (fs_extra_1.default.existsSync(configPath)) {
916
- try {
917
- const config = fs_extra_1.default.readJsonSync(configPath);
918
- if (config.entity) {
919
- // It's an entity directory, add it
920
- dirs.push(fullPath);
921
- }
922
- else {
923
- // It's a container directory, search its subdirectories
924
- this.findEntityDirectoriesRecursive(fullPath, dirs);
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
- catch {
928
- // Invalid config, skip
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
- else {
934
- // Find all entity directories
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 dirs;
1191
+ return { deleted, errors };
959
1192
  }
960
- findEntityDirectoriesRecursive(dir, dirs) {
961
- const entries = fs_extra_1.default.readdirSync(dir, { withFileTypes: true });
962
- for (const entry of entries) {
963
- if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
964
- const fullPath = path_1.default.join(dir, entry.name);
965
- const configPath = path_1.default.join(fullPath, '.mj-sync.json');
966
- if (fs_extra_1.default.existsSync(configPath)) {
967
- try {
968
- const config = fs_extra_1.default.readJsonSync(configPath);
969
- if (config.entity) {
970
- dirs.push(fullPath);
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
- catch {
974
- // Skip invalid config files
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
- // Recurse into subdirectories
979
- this.findEntityDirectoriesRecursive(fullPath, dirs);
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;