@memberjunction/metadata-sync 2.50.0 → 2.52.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 +423 -2
- package/dist/commands/file-reset/index.d.ts +15 -0
- package/dist/commands/file-reset/index.js +221 -0
- package/dist/commands/file-reset/index.js.map +1 -0
- package/dist/commands/pull/index.d.ts +1 -0
- package/dist/commands/pull/index.js +82 -10
- package/dist/commands/pull/index.js.map +1 -1
- package/dist/commands/push/index.d.ts +21 -0
- package/dist/commands/push/index.js +589 -45
- package/dist/commands/push/index.js.map +1 -1
- package/dist/commands/validate/index.d.ts +15 -0
- package/dist/commands/validate/index.js +149 -0
- package/dist/commands/validate/index.js.map +1 -0
- package/dist/commands/watch/index.js +39 -1
- package/dist/commands/watch/index.js.map +1 -1
- package/dist/config.d.ts +7 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/file-backup-manager.d.ts +90 -0
- package/dist/lib/file-backup-manager.js +186 -0
- package/dist/lib/file-backup-manager.js.map +1 -0
- package/dist/lib/provider-utils.d.ts +2 -2
- package/dist/lib/provider-utils.js +3 -4
- package/dist/lib/provider-utils.js.map +1 -1
- package/dist/lib/sync-engine.js +29 -3
- package/dist/lib/sync-engine.js.map +1 -1
- package/dist/services/FormattingService.d.ts +45 -0
- package/dist/services/FormattingService.js +564 -0
- package/dist/services/FormattingService.js.map +1 -0
- package/dist/services/ValidationService.d.ts +110 -0
- package/dist/services/ValidationService.js +737 -0
- package/dist/services/ValidationService.js.map +1 -0
- package/dist/types/validation.d.ts +98 -0
- package/dist/types/validation.js +97 -0
- package/dist/types/validation.js.map +1 -0
- package/oclif.manifest.json +205 -39
- package/package.json +7 -7
|
@@ -29,16 +29,23 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
29
29
|
const core_1 = require("@oclif/core");
|
|
30
30
|
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
31
31
|
const path_1 = __importDefault(require("path"));
|
|
32
|
+
const prompts_1 = require("@inquirer/prompts");
|
|
32
33
|
const ora_classic_1 = __importDefault(require("ora-classic"));
|
|
33
34
|
const fast_glob_1 = __importDefault(require("fast-glob"));
|
|
35
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
34
36
|
const config_1 = require("../../config");
|
|
35
37
|
const provider_utils_1 = require("../../lib/provider-utils");
|
|
36
|
-
const
|
|
38
|
+
const core_2 = require("@memberjunction/core");
|
|
37
39
|
const config_manager_1 = require("../../lib/config-manager");
|
|
38
40
|
const singleton_manager_1 = require("../../lib/singleton-manager");
|
|
41
|
+
const sqlserver_dataprovider_1 = require("@memberjunction/sqlserver-dataprovider");
|
|
39
42
|
const global_1 = require("@memberjunction/global");
|
|
43
|
+
const file_backup_manager_1 = require("../../lib/file-backup-manager");
|
|
40
44
|
class Push extends core_1.Command {
|
|
41
45
|
static description = 'Push local file changes to the database';
|
|
46
|
+
warnings = [];
|
|
47
|
+
errors = [];
|
|
48
|
+
processedRecords = new Map();
|
|
42
49
|
static examples = [
|
|
43
50
|
`<%= config.bin %> <%= command.id %>`,
|
|
44
51
|
`<%= config.bin %> <%= command.id %> --dry-run`,
|
|
@@ -50,11 +57,23 @@ class Push extends core_1.Command {
|
|
|
50
57
|
'dry-run': core_1.Flags.boolean({ description: 'Show what would be pushed without actually pushing' }),
|
|
51
58
|
ci: core_1.Flags.boolean({ description: 'CI mode - no prompts, fail on issues' }),
|
|
52
59
|
verbose: core_1.Flags.boolean({ char: 'v', description: 'Show detailed field-level output' }),
|
|
60
|
+
'no-validate': core_1.Flags.boolean({ description: 'Skip validation before push' }),
|
|
53
61
|
};
|
|
62
|
+
// Override warn to collect warnings
|
|
63
|
+
warn(input) {
|
|
64
|
+
const message = typeof input === 'string' ? input : input.message;
|
|
65
|
+
this.warnings.push(message);
|
|
66
|
+
return super.warn(input);
|
|
67
|
+
}
|
|
54
68
|
async run() {
|
|
55
69
|
const { flags } = await this.parse(Push);
|
|
56
70
|
const spinner = (0, ora_classic_1.default)();
|
|
57
71
|
let sqlLogger = null;
|
|
72
|
+
const fileBackupManager = new file_backup_manager_1.FileBackupManager();
|
|
73
|
+
let hasActiveTransaction = false;
|
|
74
|
+
const startTime = Date.now();
|
|
75
|
+
// Reset the processed records tracking for this push operation
|
|
76
|
+
this.processedRecords.clear();
|
|
58
77
|
try {
|
|
59
78
|
// Load configurations
|
|
60
79
|
spinner.start('Loading configuration');
|
|
@@ -72,7 +91,12 @@ class Push extends core_1.Command {
|
|
|
72
91
|
// Initialize sync engine using singleton pattern
|
|
73
92
|
const syncEngine = await (0, singleton_manager_1.getSyncEngine)((0, provider_utils_1.getSystemUser)());
|
|
74
93
|
// Show success after all initialization is complete
|
|
75
|
-
|
|
94
|
+
if (flags.verbose) {
|
|
95
|
+
spinner.succeed('Configuration and metadata loaded');
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
spinner.stop();
|
|
99
|
+
}
|
|
76
100
|
// Initialize SQL logging AFTER provider setup is complete
|
|
77
101
|
if (syncConfig?.sqlLogging?.enabled) {
|
|
78
102
|
const outputDir = syncConfig.sqlLogging.outputDirectory || './sql_logging';
|
|
@@ -96,18 +120,20 @@ class Push extends core_1.Command {
|
|
|
96
120
|
// Import and access the data provider from the provider utils
|
|
97
121
|
const { getDataProvider } = await Promise.resolve().then(() => __importStar(require('../../lib/provider-utils')));
|
|
98
122
|
const dataProvider = getDataProvider();
|
|
99
|
-
if (dataProvider && typeof dataProvider.
|
|
100
|
-
sqlLogger = await dataProvider.
|
|
123
|
+
if (dataProvider && typeof dataProvider.CreateSqlLogger === 'function') {
|
|
124
|
+
sqlLogger = await dataProvider.CreateSqlLogger(logFilePath, {
|
|
101
125
|
formatAsMigration,
|
|
102
126
|
description: 'MetadataSync Push Operation',
|
|
103
127
|
statementTypes: 'mutations', // Only log mutations (data changes)
|
|
104
128
|
batchSeparator: 'GO', // Add GO statements for SQL Server batch processing
|
|
105
129
|
prettyPrint: true // Enable pretty printing for readable output
|
|
106
130
|
});
|
|
107
|
-
|
|
131
|
+
if (flags.verbose) {
|
|
132
|
+
this.log(`📝 SQL logging enabled: ${path_1.default.relative(process.cwd(), logFilePath)}`);
|
|
133
|
+
}
|
|
108
134
|
}
|
|
109
135
|
else {
|
|
110
|
-
this.warn('SQL logging requested but data provider does not support
|
|
136
|
+
this.warn('SQL logging requested but data provider does not support CreateSqlLogger');
|
|
111
137
|
}
|
|
112
138
|
}
|
|
113
139
|
// Find entity directories to process
|
|
@@ -115,10 +141,104 @@ class Push extends core_1.Command {
|
|
|
115
141
|
if (entityDirs.length === 0) {
|
|
116
142
|
this.error('No entity directories found');
|
|
117
143
|
}
|
|
118
|
-
|
|
144
|
+
if (flags.verbose) {
|
|
145
|
+
this.log(`Found ${entityDirs.length} entity ${entityDirs.length === 1 ? 'directory' : 'directories'} to process`);
|
|
146
|
+
}
|
|
147
|
+
// Run validation unless disabled
|
|
148
|
+
if (!flags['no-validate']) {
|
|
149
|
+
const { ValidationService } = await Promise.resolve().then(() => __importStar(require('../../services/ValidationService')));
|
|
150
|
+
const { FormattingService } = await Promise.resolve().then(() => __importStar(require('../../services/FormattingService')));
|
|
151
|
+
spinner.start('Validating metadata...');
|
|
152
|
+
const validator = new ValidationService({ verbose: flags.verbose });
|
|
153
|
+
const formatter = new FormattingService();
|
|
154
|
+
const targetDir = flags.dir ? path_1.default.resolve(config_manager_1.configManager.getOriginalCwd(), flags.dir) : config_manager_1.configManager.getOriginalCwd();
|
|
155
|
+
const validationResult = await validator.validateDirectory(targetDir);
|
|
156
|
+
spinner.stop();
|
|
157
|
+
if (!validationResult.isValid || validationResult.warnings.length > 0) {
|
|
158
|
+
// Show validation results
|
|
159
|
+
this.log('\n' + formatter.formatValidationResult(validationResult, flags.verbose));
|
|
160
|
+
if (!validationResult.isValid) {
|
|
161
|
+
// In CI mode, fail immediately
|
|
162
|
+
if (flags.ci) {
|
|
163
|
+
this.error('Validation failed. Cannot proceed with push.');
|
|
164
|
+
}
|
|
165
|
+
// Otherwise, ask for confirmation
|
|
166
|
+
const shouldContinue = await (0, prompts_1.confirm)({
|
|
167
|
+
message: 'Validation failed with errors. Do you want to continue anyway?',
|
|
168
|
+
default: false
|
|
169
|
+
});
|
|
170
|
+
if (!shouldContinue) {
|
|
171
|
+
this.error('Push cancelled due to validation errors.');
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
this.log(chalk_1.default.green('✓ Validation passed'));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// Initialize file backup manager (unless in dry-run mode)
|
|
180
|
+
if (!flags['dry-run']) {
|
|
181
|
+
await fileBackupManager.initialize();
|
|
182
|
+
if (flags.verbose) {
|
|
183
|
+
this.log('📁 File backup manager initialized');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// Start a database transaction for the entire push operation (unless in dry-run mode)
|
|
187
|
+
// IMPORTANT: We start the transaction AFTER metadata loading and validation to avoid
|
|
188
|
+
// transaction conflicts with background refresh operations
|
|
189
|
+
if (!flags['dry-run']) {
|
|
190
|
+
const { getDataProvider } = await Promise.resolve().then(() => __importStar(require('../../lib/provider-utils')));
|
|
191
|
+
const dataProvider = getDataProvider();
|
|
192
|
+
// Ensure we have SQLServerDataProvider for transaction support
|
|
193
|
+
if (!(dataProvider instanceof sqlserver_dataprovider_1.SQLServerDataProvider)) {
|
|
194
|
+
const errorMsg = 'MetadataSync requires SQLServerDataProvider for transaction support. Current provider does not support transactions.';
|
|
195
|
+
// Rollback file backups since we're not proceeding
|
|
196
|
+
try {
|
|
197
|
+
await fileBackupManager.rollback();
|
|
198
|
+
}
|
|
199
|
+
catch (rollbackError) {
|
|
200
|
+
this.warn(`Failed to rollback file backup initialization: ${rollbackError}`);
|
|
201
|
+
}
|
|
202
|
+
this.error(errorMsg);
|
|
203
|
+
}
|
|
204
|
+
if (dataProvider && typeof dataProvider.BeginTransaction === 'function') {
|
|
205
|
+
try {
|
|
206
|
+
await dataProvider.BeginTransaction();
|
|
207
|
+
hasActiveTransaction = true;
|
|
208
|
+
if (flags.verbose) {
|
|
209
|
+
this.log('🔄 Transaction started - all changes will be committed or rolled back as a unit');
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
// Transaction start failure is critical - we should not proceed without it
|
|
214
|
+
const errorMsg = `Failed to start database transaction: ${error instanceof Error ? error.message : String(error)}`;
|
|
215
|
+
// Rollback file backups since we're not proceeding
|
|
216
|
+
try {
|
|
217
|
+
await fileBackupManager.rollback();
|
|
218
|
+
}
|
|
219
|
+
catch (rollbackError) {
|
|
220
|
+
this.warn(`Failed to rollback file backup initialization: ${rollbackError}`);
|
|
221
|
+
}
|
|
222
|
+
this.error(errorMsg);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
// No transaction support is also critical for data integrity
|
|
227
|
+
const errorMsg = 'Transaction support not available - cannot ensure data integrity';
|
|
228
|
+
// Rollback file backups since we're not proceeding
|
|
229
|
+
try {
|
|
230
|
+
await fileBackupManager.rollback();
|
|
231
|
+
}
|
|
232
|
+
catch (rollbackError) {
|
|
233
|
+
this.warn(`Failed to rollback file backup initialization: ${rollbackError}`);
|
|
234
|
+
}
|
|
235
|
+
this.error(errorMsg);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
119
238
|
// Process each entity directory
|
|
120
239
|
let totalCreated = 0;
|
|
121
240
|
let totalUpdated = 0;
|
|
241
|
+
let totalUnchanged = 0;
|
|
122
242
|
let totalErrors = 0;
|
|
123
243
|
for (const entityDir of entityDirs) {
|
|
124
244
|
const entityConfig = await (0, config_1.loadEntityConfig)(entityDir);
|
|
@@ -126,23 +246,156 @@ class Push extends core_1.Command {
|
|
|
126
246
|
this.warn(`Skipping ${entityDir} - no valid entity configuration`);
|
|
127
247
|
continue;
|
|
128
248
|
}
|
|
129
|
-
|
|
130
|
-
|
|
249
|
+
if (flags.verbose) {
|
|
250
|
+
this.log(`\nProcessing ${entityConfig.entity} in ${entityDir}`);
|
|
251
|
+
}
|
|
252
|
+
const result = await this.processEntityDirectory(entityDir, entityConfig, syncEngine, flags, syncConfig, fileBackupManager);
|
|
253
|
+
// Show per-directory summary
|
|
254
|
+
const dirName = path_1.default.relative(process.cwd(), entityDir) || '.';
|
|
255
|
+
const dirTotal = result.created + result.updated + result.unchanged;
|
|
256
|
+
if (dirTotal > 0 || result.errors > 0) {
|
|
257
|
+
this.log(`\n📁 ${dirName}:`);
|
|
258
|
+
this.log(` Total processed: ${dirTotal} unique records`);
|
|
259
|
+
if (result.created > 0) {
|
|
260
|
+
this.log(` ✓ Created: ${result.created}`);
|
|
261
|
+
}
|
|
262
|
+
if (result.updated > 0) {
|
|
263
|
+
this.log(` ✓ Updated: ${result.updated}`);
|
|
264
|
+
}
|
|
265
|
+
if (result.unchanged > 0) {
|
|
266
|
+
this.log(` - Unchanged: ${result.unchanged}`);
|
|
267
|
+
}
|
|
268
|
+
if (result.errors > 0) {
|
|
269
|
+
this.log(` ✗ Errors: ${result.errors}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
131
272
|
totalCreated += result.created;
|
|
132
273
|
totalUpdated += result.updated;
|
|
274
|
+
totalUnchanged += result.unchanged;
|
|
133
275
|
totalErrors += result.errors;
|
|
134
276
|
}
|
|
135
|
-
// Summary
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
this.log(
|
|
140
|
-
|
|
141
|
-
|
|
277
|
+
// Summary using FormattingService
|
|
278
|
+
const endTime = Date.now();
|
|
279
|
+
const { FormattingService } = await Promise.resolve().then(() => __importStar(require('../../services/FormattingService')));
|
|
280
|
+
const formatter = new FormattingService();
|
|
281
|
+
this.log('\n' + formatter.formatSyncSummary('push', {
|
|
282
|
+
created: totalCreated,
|
|
283
|
+
updated: totalUpdated,
|
|
284
|
+
unchanged: totalUnchanged,
|
|
285
|
+
deleted: 0,
|
|
286
|
+
skipped: 0,
|
|
287
|
+
errors: totalErrors,
|
|
288
|
+
duration: endTime - startTime
|
|
289
|
+
}));
|
|
290
|
+
// Handle transaction commit/rollback
|
|
291
|
+
if (!flags['dry-run'] && hasActiveTransaction) {
|
|
292
|
+
const dataProvider = core_2.Metadata.Provider;
|
|
293
|
+
// We know we have an active transaction at this point
|
|
294
|
+
if (dataProvider) {
|
|
295
|
+
let shouldCommit = true;
|
|
296
|
+
// If there are any errors, always rollback
|
|
297
|
+
if (totalErrors > 0 || this.errors.length > 0) {
|
|
298
|
+
shouldCommit = false;
|
|
299
|
+
this.log('\n❌ Errors detected - rolling back all changes');
|
|
300
|
+
}
|
|
301
|
+
// If there are warnings, ask user (unless in CI mode)
|
|
302
|
+
else if (this.warnings.length > 0) {
|
|
303
|
+
// Filter out transaction-related warnings since we're now using transactions
|
|
304
|
+
const nonTransactionWarnings = this.warnings.filter(w => !w.includes('Transaction support not available') &&
|
|
305
|
+
!w.includes('Failed to start transaction'));
|
|
306
|
+
if (nonTransactionWarnings.length > 0) {
|
|
307
|
+
if (flags.ci) {
|
|
308
|
+
// In CI mode, rollback on warnings
|
|
309
|
+
shouldCommit = false;
|
|
310
|
+
this.log('\n⚠️ Warnings detected in CI mode - rolling back all changes');
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
// Show warnings to user
|
|
314
|
+
this.log('\n⚠️ The following warnings were encountered:');
|
|
315
|
+
for (const warning of nonTransactionWarnings) {
|
|
316
|
+
this.log(` - ${warning}`);
|
|
317
|
+
}
|
|
318
|
+
// Ask user whether to commit or rollback
|
|
319
|
+
shouldCommit = await (0, prompts_1.confirm)({
|
|
320
|
+
message: 'Do you want to commit these changes despite the warnings?',
|
|
321
|
+
default: false // Default to rollback
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
try {
|
|
327
|
+
if (shouldCommit) {
|
|
328
|
+
await dataProvider.CommitTransaction();
|
|
329
|
+
this.log('\n✅ All changes committed successfully');
|
|
330
|
+
// Clean up file backups after successful commit
|
|
331
|
+
await fileBackupManager.cleanup();
|
|
332
|
+
}
|
|
333
|
+
else {
|
|
334
|
+
// User chose to rollback or errors/warnings in CI mode
|
|
335
|
+
this.log('\n🔙 Rolling back all changes...');
|
|
336
|
+
// Rollback database transaction
|
|
337
|
+
await dataProvider.RollbackTransaction();
|
|
338
|
+
// Rollback file changes
|
|
339
|
+
this.log('🔙 Rolling back file changes...');
|
|
340
|
+
await fileBackupManager.rollback();
|
|
341
|
+
this.log('✅ Rollback completed - no changes were made to the database or files');
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
catch (error) {
|
|
345
|
+
// Try to rollback on any error
|
|
346
|
+
this.log('\n❌ Transaction error - attempting to roll back changes');
|
|
347
|
+
try {
|
|
348
|
+
await dataProvider.RollbackTransaction();
|
|
349
|
+
this.log('✅ Database rollback completed');
|
|
350
|
+
}
|
|
351
|
+
catch (rollbackError) {
|
|
352
|
+
this.log('❌ Database rollback failed: ' + (rollbackError instanceof Error ? rollbackError.message : String(rollbackError)));
|
|
353
|
+
}
|
|
354
|
+
// Also rollback file changes
|
|
355
|
+
try {
|
|
356
|
+
this.log('🔙 Rolling back file changes...');
|
|
357
|
+
await fileBackupManager.rollback();
|
|
358
|
+
this.log('✅ File rollback completed');
|
|
359
|
+
}
|
|
360
|
+
catch (fileRollbackError) {
|
|
361
|
+
this.log('❌ File rollback failed: ' + (fileRollbackError instanceof Error ? fileRollbackError.message : String(fileRollbackError)));
|
|
362
|
+
}
|
|
363
|
+
throw error;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// Exit with error if there were errors in CI mode
|
|
368
|
+
if ((totalErrors > 0 || this.errors.length > 0 || (this.warnings.length > 0 && flags.ci)) && flags.ci) {
|
|
369
|
+
this.error('Push failed in CI mode');
|
|
142
370
|
}
|
|
143
371
|
}
|
|
144
372
|
catch (error) {
|
|
145
373
|
spinner.fail('Push failed');
|
|
374
|
+
// Try to rollback the transaction and files if not in dry-run mode
|
|
375
|
+
if (!flags['dry-run']) {
|
|
376
|
+
const { getDataProvider } = await Promise.resolve().then(() => __importStar(require('../../lib/provider-utils')));
|
|
377
|
+
const dataProvider = getDataProvider();
|
|
378
|
+
// Rollback database transaction if we have one
|
|
379
|
+
if (hasActiveTransaction && dataProvider && typeof dataProvider.RollbackTransaction === 'function') {
|
|
380
|
+
try {
|
|
381
|
+
this.log('\n🔙 Rolling back database transaction due to error...');
|
|
382
|
+
await dataProvider.RollbackTransaction();
|
|
383
|
+
this.log('✅ Database rollback completed');
|
|
384
|
+
}
|
|
385
|
+
catch (rollbackError) {
|
|
386
|
+
this.log('❌ Database rollback failed: ' + (rollbackError instanceof Error ? rollbackError.message : String(rollbackError)));
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
// Rollback file changes
|
|
390
|
+
try {
|
|
391
|
+
this.log('🔙 Rolling back file changes...');
|
|
392
|
+
await fileBackupManager.rollback();
|
|
393
|
+
this.log('✅ File rollback completed - all files restored to original state');
|
|
394
|
+
}
|
|
395
|
+
catch (fileRollbackError) {
|
|
396
|
+
this.log('❌ File rollback failed: ' + (fileRollbackError instanceof Error ? fileRollbackError.message : String(fileRollbackError)));
|
|
397
|
+
}
|
|
398
|
+
}
|
|
146
399
|
// Enhanced error logging for debugging
|
|
147
400
|
this.log('\n=== Push Error Details ===');
|
|
148
401
|
this.log(`Error type: ${error?.constructor?.name || 'Unknown'}`);
|
|
@@ -176,7 +429,9 @@ class Push extends core_1.Command {
|
|
|
176
429
|
if (sqlLogger) {
|
|
177
430
|
try {
|
|
178
431
|
await sqlLogger.dispose();
|
|
179
|
-
|
|
432
|
+
if (flags.verbose) {
|
|
433
|
+
this.log('✅ SQL logging session closed');
|
|
434
|
+
}
|
|
180
435
|
}
|
|
181
436
|
catch (error) {
|
|
182
437
|
this.warn(`Failed to close SQL logging session: ${error}`);
|
|
@@ -184,14 +439,13 @@ class Push extends core_1.Command {
|
|
|
184
439
|
}
|
|
185
440
|
// Reset sync engine singleton
|
|
186
441
|
(0, singleton_manager_1.resetSyncEngine)();
|
|
187
|
-
// Clean up database connection
|
|
188
|
-
await (0, provider_utils_2.cleanupProvider)();
|
|
189
442
|
// Exit process to prevent background MJ tasks from throwing errors
|
|
443
|
+
// We don't explicitly close the connection - let the process termination handle it
|
|
190
444
|
process.exit(0);
|
|
191
445
|
}
|
|
192
446
|
}
|
|
193
|
-
async processEntityDirectory(entityDir, entityConfig, syncEngine, flags, syncConfig) {
|
|
194
|
-
const result = { created: 0, updated: 0, errors: 0 };
|
|
447
|
+
async processEntityDirectory(entityDir, entityConfig, syncEngine, flags, syncConfig, fileBackupManager) {
|
|
448
|
+
const result = { created: 0, updated: 0, unchanged: 0, errors: 0 };
|
|
195
449
|
// Find files matching the configured pattern
|
|
196
450
|
const pattern = entityConfig.filePattern || '*.json';
|
|
197
451
|
const jsonFiles = await (0, fast_glob_1.default)(pattern, {
|
|
@@ -200,9 +454,11 @@ class Push extends core_1.Command {
|
|
|
200
454
|
dot: true, // Include dotfiles (files starting with .)
|
|
201
455
|
onlyFiles: true
|
|
202
456
|
});
|
|
203
|
-
|
|
457
|
+
if (flags.verbose) {
|
|
458
|
+
this.log(`Processing ${jsonFiles.length} records in ${path_1.default.relative(process.cwd(), entityDir) || '.'}`);
|
|
459
|
+
}
|
|
204
460
|
// First, process all JSON files in this directory
|
|
205
|
-
await this.processJsonFiles(jsonFiles, entityDir, entityConfig, syncEngine, flags, result);
|
|
461
|
+
await this.processJsonFiles(jsonFiles, entityDir, entityConfig, syncEngine, flags, result, fileBackupManager);
|
|
206
462
|
// Then, recursively process subdirectories
|
|
207
463
|
const entries = await fs_extra_1.default.readdir(entityDir, { withFileTypes: true });
|
|
208
464
|
for (const entry of entries) {
|
|
@@ -228,47 +484,68 @@ class Push extends core_1.Command {
|
|
|
228
484
|
};
|
|
229
485
|
}
|
|
230
486
|
// Process subdirectory with merged config
|
|
231
|
-
const subResult = await this.processEntityDirectory(subDir, subEntityConfig, syncEngine, flags, syncConfig);
|
|
487
|
+
const subResult = await this.processEntityDirectory(subDir, subEntityConfig, syncEngine, flags, syncConfig, fileBackupManager);
|
|
232
488
|
result.created += subResult.created;
|
|
233
489
|
result.updated += subResult.updated;
|
|
490
|
+
result.unchanged += subResult.unchanged;
|
|
234
491
|
result.errors += subResult.errors;
|
|
235
492
|
}
|
|
236
493
|
}
|
|
237
494
|
return result;
|
|
238
495
|
}
|
|
239
|
-
async processJsonFiles(jsonFiles, entityDir, entityConfig, syncEngine, flags, result) {
|
|
496
|
+
async processJsonFiles(jsonFiles, entityDir, entityConfig, syncEngine, flags, result, fileBackupManager) {
|
|
240
497
|
if (jsonFiles.length === 0) {
|
|
241
498
|
return;
|
|
242
499
|
}
|
|
243
500
|
const spinner = (0, ora_classic_1.default)();
|
|
244
501
|
spinner.start('Processing records');
|
|
245
|
-
let totalRecords = 0;
|
|
246
502
|
for (const file of jsonFiles) {
|
|
247
503
|
try {
|
|
248
504
|
const filePath = path_1.default.join(entityDir, file);
|
|
249
|
-
|
|
505
|
+
// Backup the file before any modifications (unless dry-run)
|
|
506
|
+
if (!flags['dry-run'] && fileBackupManager) {
|
|
507
|
+
await fileBackupManager.backupFile(filePath);
|
|
508
|
+
}
|
|
509
|
+
// Parse JSON with line number tracking
|
|
510
|
+
const { content: fileContent, lineNumbers } = await this.parseJsonWithLineNumbers(filePath);
|
|
250
511
|
// Process templates in the loaded content
|
|
251
512
|
const processedContent = await syncEngine.processTemplates(fileContent, entityDir);
|
|
252
513
|
// Check if the file contains a single record or an array of records
|
|
253
514
|
const isArray = Array.isArray(processedContent);
|
|
254
515
|
const records = isArray ? processedContent : [processedContent];
|
|
255
|
-
totalRecords += records.length;
|
|
256
516
|
// Build and process defaults (including lookups)
|
|
257
517
|
const defaults = await syncEngine.buildDefaults(filePath, entityConfig);
|
|
258
518
|
// Process each record in the file
|
|
259
519
|
for (let i = 0; i < records.length; i++) {
|
|
260
520
|
const recordData = records[i];
|
|
261
521
|
// Process the record
|
|
262
|
-
const
|
|
522
|
+
const recordLineNumber = lineNumbers.get(i); // Get line number for this array index
|
|
523
|
+
const pushResult = await this.pushRecord(recordData, entityConfig.entity, path_1.default.dirname(filePath), file, defaults, syncEngine, flags['dry-run'], flags.verbose, isArray ? i : undefined, fileBackupManager, recordLineNumber);
|
|
263
524
|
if (!flags['dry-run']) {
|
|
264
|
-
|
|
265
|
-
|
|
525
|
+
// Don't count duplicates in stats
|
|
526
|
+
if (!pushResult.isDuplicate) {
|
|
527
|
+
if (pushResult.isNew) {
|
|
528
|
+
result.created++;
|
|
529
|
+
}
|
|
530
|
+
else if (pushResult.wasActuallyUpdated) {
|
|
531
|
+
result.updated++;
|
|
532
|
+
}
|
|
533
|
+
else {
|
|
534
|
+
result.unchanged++;
|
|
535
|
+
}
|
|
266
536
|
}
|
|
267
|
-
|
|
268
|
-
|
|
537
|
+
// Add related entity stats
|
|
538
|
+
if (pushResult.relatedStats) {
|
|
539
|
+
result.created += pushResult.relatedStats.created;
|
|
540
|
+
result.updated += pushResult.relatedStats.updated;
|
|
541
|
+
result.unchanged += pushResult.relatedStats.unchanged;
|
|
542
|
+
// Debug logging for related entities
|
|
543
|
+
if (flags.verbose && pushResult.relatedStats.unchanged > 0) {
|
|
544
|
+
this.log(` Related entities: ${pushResult.relatedStats.unchanged} unchanged`);
|
|
545
|
+
}
|
|
269
546
|
}
|
|
270
547
|
}
|
|
271
|
-
spinner.text = `Processing records (${result.created + result.updated + result.errors}
|
|
548
|
+
spinner.text = `Processing records (${result.created + result.updated + result.unchanged + result.errors} processed)`;
|
|
272
549
|
}
|
|
273
550
|
// Write back the entire file if it's an array
|
|
274
551
|
if (isArray && !flags['dry-run']) {
|
|
@@ -278,17 +555,45 @@ class Push extends core_1.Command {
|
|
|
278
555
|
catch (error) {
|
|
279
556
|
result.errors++;
|
|
280
557
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
281
|
-
|
|
558
|
+
const fullErrorMessage = `Failed to process ${file}: ${errorMessage}`;
|
|
559
|
+
this.errors.push(fullErrorMessage);
|
|
560
|
+
this.error(fullErrorMessage, { exit: false });
|
|
282
561
|
}
|
|
283
562
|
}
|
|
284
|
-
|
|
563
|
+
if (flags.verbose) {
|
|
564
|
+
spinner.succeed(`Processed ${result.created + result.updated + result.unchanged} records from ${jsonFiles.length} files`);
|
|
565
|
+
}
|
|
566
|
+
else {
|
|
567
|
+
spinner.stop();
|
|
568
|
+
}
|
|
285
569
|
}
|
|
286
|
-
async pushRecord(recordData, entityName, baseDir, fileName, defaults, syncEngine, dryRun, verbose = false, arrayIndex) {
|
|
570
|
+
async pushRecord(recordData, entityName, baseDir, fileName, defaults, syncEngine, dryRun, verbose = false, arrayIndex, fileBackupManager, lineNumber) {
|
|
287
571
|
// Load or create entity
|
|
288
572
|
let entity = null;
|
|
289
573
|
let isNew = false;
|
|
290
574
|
if (recordData.primaryKey) {
|
|
291
575
|
entity = await syncEngine.loadEntity(entityName, recordData.primaryKey);
|
|
576
|
+
// Warn if record has primaryKey but wasn't found
|
|
577
|
+
if (!entity) {
|
|
578
|
+
const pkDisplay = Object.entries(recordData.primaryKey)
|
|
579
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
580
|
+
.join(', ');
|
|
581
|
+
// Load sync config to check autoCreateMissingRecords setting
|
|
582
|
+
const syncConfig = await (0, config_1.loadSyncConfig)(config_manager_1.configManager.getOriginalCwd());
|
|
583
|
+
const autoCreate = syncConfig?.push?.autoCreateMissingRecords ?? false;
|
|
584
|
+
if (!autoCreate) {
|
|
585
|
+
const fileRef = lineNumber ? `${fileName}:${lineNumber}` : fileName;
|
|
586
|
+
this.warn(`⚠️ Record not found: ${entityName} with primaryKey {${pkDisplay}} at ${fileRef}`);
|
|
587
|
+
this.warn(` To auto-create missing records, set push.autoCreateMissingRecords=true in .mj-sync.json`);
|
|
588
|
+
// Skip this record
|
|
589
|
+
return { isNew: false, wasActuallyUpdated: false, isDuplicate: false };
|
|
590
|
+
}
|
|
591
|
+
else {
|
|
592
|
+
if (verbose) {
|
|
593
|
+
this.log(` Auto-creating missing ${entityName} record with primaryKey {${pkDisplay}}`);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
292
597
|
}
|
|
293
598
|
if (!entity) {
|
|
294
599
|
// New record
|
|
@@ -349,7 +654,46 @@ class Push extends core_1.Command {
|
|
|
349
654
|
}
|
|
350
655
|
if (dryRun) {
|
|
351
656
|
this.log(`Would ${isNew ? 'create' : 'update'} ${entityName} record`);
|
|
352
|
-
return isNew;
|
|
657
|
+
return { isNew, wasActuallyUpdated: true, isDuplicate: false, relatedStats: undefined };
|
|
658
|
+
}
|
|
659
|
+
// Check for duplicate processing (but only for existing records that were loaded)
|
|
660
|
+
let isDuplicate = false;
|
|
661
|
+
if (!isNew && entity) {
|
|
662
|
+
const fullFilePath = path_1.default.join(baseDir, fileName);
|
|
663
|
+
isDuplicate = this.checkAndTrackRecord(entityName, entity, fullFilePath, arrayIndex, lineNumber);
|
|
664
|
+
}
|
|
665
|
+
// Check if the record is dirty before saving
|
|
666
|
+
let wasActuallyUpdated = false;
|
|
667
|
+
if (!isNew && entity.Dirty) {
|
|
668
|
+
// Record is dirty, get the changes
|
|
669
|
+
const changes = entity.GetChangesSinceLastSave();
|
|
670
|
+
const changeKeys = Object.keys(changes);
|
|
671
|
+
if (changeKeys.length > 0) {
|
|
672
|
+
wasActuallyUpdated = true;
|
|
673
|
+
// Get primary key info for display
|
|
674
|
+
const entityInfo = syncEngine.getEntityInfo(entityName);
|
|
675
|
+
const primaryKeyDisplay = [];
|
|
676
|
+
if (entityInfo) {
|
|
677
|
+
for (const pk of entityInfo.PrimaryKeys) {
|
|
678
|
+
primaryKeyDisplay.push(`${pk.Name}: ${entity.Get(pk.Name)}`);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
this.log(''); // Add newline before update output
|
|
682
|
+
this.log(`📝 Updating ${entityName} record:`);
|
|
683
|
+
if (primaryKeyDisplay.length > 0) {
|
|
684
|
+
this.log(` Primary Key: ${primaryKeyDisplay.join(', ')}`);
|
|
685
|
+
}
|
|
686
|
+
this.log(` Changes:`);
|
|
687
|
+
for (const fieldName of changeKeys) {
|
|
688
|
+
const field = entity.GetFieldByName(fieldName);
|
|
689
|
+
const oldValue = field ? field.OldValue : undefined;
|
|
690
|
+
const newValue = changes[fieldName];
|
|
691
|
+
this.log(` ${fieldName}: ${oldValue} → ${newValue}`);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
else if (isNew) {
|
|
696
|
+
wasActuallyUpdated = true;
|
|
353
697
|
}
|
|
354
698
|
// Save the record
|
|
355
699
|
const saved = await entity.Save();
|
|
@@ -362,9 +706,12 @@ class Push extends core_1.Command {
|
|
|
362
706
|
throw new Error(`Failed to save record: ${errors}`);
|
|
363
707
|
}
|
|
364
708
|
// Process related entities after saving parent
|
|
709
|
+
let relatedStats;
|
|
365
710
|
if (recordData.relatedEntities && !dryRun) {
|
|
366
|
-
|
|
367
|
-
|
|
711
|
+
const fullFilePath = path_1.default.join(baseDir, fileName);
|
|
712
|
+
relatedStats = await this.processRelatedEntities(recordData.relatedEntities, entity, entity, // root is same as parent for top level
|
|
713
|
+
baseDir, syncEngine, verbose, fileBackupManager, 1, // indentLevel
|
|
714
|
+
fullFilePath, arrayIndex);
|
|
368
715
|
}
|
|
369
716
|
// Update the local file with new primary key if created
|
|
370
717
|
if (isNew) {
|
|
@@ -376,6 +723,9 @@ class Push extends core_1.Command {
|
|
|
376
723
|
}
|
|
377
724
|
recordData.primaryKey = newPrimaryKey;
|
|
378
725
|
}
|
|
726
|
+
// Track the new record now that we have its primary key
|
|
727
|
+
const fullFilePath = path_1.default.join(baseDir, fileName);
|
|
728
|
+
this.checkAndTrackRecord(entityName, entity, fullFilePath, arrayIndex, lineNumber);
|
|
379
729
|
}
|
|
380
730
|
// Always update sync metadata
|
|
381
731
|
// This ensures related entities are persisted with their metadata
|
|
@@ -389,12 +739,15 @@ class Push extends core_1.Command {
|
|
|
389
739
|
const filePath = path_1.default.join(baseDir, fileName);
|
|
390
740
|
await fs_extra_1.default.writeJson(filePath, recordData, { spaces: 2 });
|
|
391
741
|
}
|
|
392
|
-
return isNew;
|
|
742
|
+
return { isNew, wasActuallyUpdated, isDuplicate, relatedStats };
|
|
393
743
|
}
|
|
394
|
-
async processRelatedEntities(relatedEntities, parentEntity, rootEntity, baseDir, syncEngine, verbose = false, indentLevel = 1) {
|
|
744
|
+
async processRelatedEntities(relatedEntities, parentEntity, rootEntity, baseDir, syncEngine, verbose = false, fileBackupManager, indentLevel = 1, parentFilePath, parentArrayIndex) {
|
|
395
745
|
const indent = ' '.repeat(indentLevel);
|
|
746
|
+
const stats = { created: 0, updated: 0, unchanged: 0 };
|
|
396
747
|
for (const [entityName, records] of Object.entries(relatedEntities)) {
|
|
397
|
-
|
|
748
|
+
if (verbose) {
|
|
749
|
+
this.log(`${indent}↳ Processing ${records.length} related ${entityName} records`);
|
|
750
|
+
}
|
|
398
751
|
for (const relatedRecord of records) {
|
|
399
752
|
try {
|
|
400
753
|
// Load or create entity
|
|
@@ -402,6 +755,27 @@ class Push extends core_1.Command {
|
|
|
402
755
|
let isNew = false;
|
|
403
756
|
if (relatedRecord.primaryKey) {
|
|
404
757
|
entity = await syncEngine.loadEntity(entityName, relatedRecord.primaryKey);
|
|
758
|
+
// Warn if record has primaryKey but wasn't found
|
|
759
|
+
if (!entity) {
|
|
760
|
+
const pkDisplay = Object.entries(relatedRecord.primaryKey)
|
|
761
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
762
|
+
.join(', ');
|
|
763
|
+
// Load sync config to check autoCreateMissingRecords setting
|
|
764
|
+
const syncConfig = await (0, config_1.loadSyncConfig)(config_manager_1.configManager.getOriginalCwd());
|
|
765
|
+
const autoCreate = syncConfig?.push?.autoCreateMissingRecords ?? false;
|
|
766
|
+
if (!autoCreate) {
|
|
767
|
+
const fileRef = parentFilePath ? path_1.default.relative(config_manager_1.configManager.getOriginalCwd(), parentFilePath) : 'unknown';
|
|
768
|
+
this.warn(`${indent}⚠️ Related record not found: ${entityName} with primaryKey {${pkDisplay}} at ${fileRef}`);
|
|
769
|
+
this.warn(`${indent} To auto-create missing records, set push.autoCreateMissingRecords=true in .mj-sync.json`);
|
|
770
|
+
// Skip this record
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
else {
|
|
774
|
+
if (verbose) {
|
|
775
|
+
this.log(`${indent} Auto-creating missing related ${entityName} record with primaryKey {${pkDisplay}}`);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
405
779
|
}
|
|
406
780
|
if (!entity) {
|
|
407
781
|
entity = await syncEngine.createEntityObject(entityName);
|
|
@@ -452,6 +826,46 @@ class Push extends core_1.Command {
|
|
|
452
826
|
this.warn(`${indent} Field '${field}' does not exist on entity '${entityName}'`);
|
|
453
827
|
}
|
|
454
828
|
}
|
|
829
|
+
// Check for duplicate processing (but only for existing records that were loaded)
|
|
830
|
+
let isDuplicate = false;
|
|
831
|
+
if (!isNew && entity) {
|
|
832
|
+
// Use parent file path for related entities since they're defined in the parent's file
|
|
833
|
+
const relatedFilePath = parentFilePath || path_1.default.join(baseDir, 'unknown');
|
|
834
|
+
isDuplicate = this.checkAndTrackRecord(entityName, entity, relatedFilePath, parentArrayIndex);
|
|
835
|
+
}
|
|
836
|
+
// Check if the record is dirty before saving
|
|
837
|
+
let wasActuallyUpdated = false;
|
|
838
|
+
if (!isNew && entity.Dirty) {
|
|
839
|
+
// Record is dirty, get the changes
|
|
840
|
+
const changes = entity.GetChangesSinceLastSave();
|
|
841
|
+
const changeKeys = Object.keys(changes);
|
|
842
|
+
if (changeKeys.length > 0) {
|
|
843
|
+
wasActuallyUpdated = true;
|
|
844
|
+
// Get primary key info for display
|
|
845
|
+
const entityInfo = syncEngine.getEntityInfo(entityName);
|
|
846
|
+
const primaryKeyDisplay = [];
|
|
847
|
+
if (entityInfo) {
|
|
848
|
+
for (const pk of entityInfo.PrimaryKeys) {
|
|
849
|
+
primaryKeyDisplay.push(`${pk.Name}: ${entity.Get(pk.Name)}`);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
this.log(''); // Add newline before update output
|
|
853
|
+
this.log(`${indent}📝 Updating related ${entityName} record:`);
|
|
854
|
+
if (primaryKeyDisplay.length > 0) {
|
|
855
|
+
this.log(`${indent} Primary Key: ${primaryKeyDisplay.join(', ')}`);
|
|
856
|
+
}
|
|
857
|
+
this.log(`${indent} Changes:`);
|
|
858
|
+
for (const fieldName of changeKeys) {
|
|
859
|
+
const field = entity.GetFieldByName(fieldName);
|
|
860
|
+
const oldValue = field ? field.OldValue : undefined;
|
|
861
|
+
const newValue = changes[fieldName];
|
|
862
|
+
this.log(`${indent} ${fieldName}: ${oldValue} → ${newValue}`);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
else if (isNew) {
|
|
867
|
+
wasActuallyUpdated = true;
|
|
868
|
+
}
|
|
455
869
|
// Save the related entity
|
|
456
870
|
const saved = await entity.Save();
|
|
457
871
|
if (!saved) {
|
|
@@ -462,9 +876,24 @@ class Push extends core_1.Command {
|
|
|
462
876
|
const errors = entity.LatestResult?.Errors?.map(err => typeof err === 'string' ? err : (err?.message || JSON.stringify(err)))?.join(', ') || 'Unknown error';
|
|
463
877
|
throw new Error(`Failed to save related ${entityName}: ${errors}`);
|
|
464
878
|
}
|
|
465
|
-
|
|
879
|
+
// Update stats - don't count duplicates
|
|
880
|
+
if (!isDuplicate) {
|
|
881
|
+
if (isNew) {
|
|
882
|
+
stats.created++;
|
|
883
|
+
}
|
|
884
|
+
else if (wasActuallyUpdated) {
|
|
885
|
+
stats.updated++;
|
|
886
|
+
}
|
|
887
|
+
else {
|
|
888
|
+
stats.unchanged++;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
if (verbose && wasActuallyUpdated) {
|
|
466
892
|
this.log(`${indent} ✓ ${isNew ? 'Created' : 'Updated'} ${entityName} record`);
|
|
467
893
|
}
|
|
894
|
+
else if (verbose && !wasActuallyUpdated) {
|
|
895
|
+
this.log(`${indent} - No changes to ${entityName} record`);
|
|
896
|
+
}
|
|
468
897
|
// Update the related record with primary key and sync metadata
|
|
469
898
|
const entityInfo = syncEngine.getEntityInfo(entityName);
|
|
470
899
|
if (entityInfo) {
|
|
@@ -474,6 +903,9 @@ class Push extends core_1.Command {
|
|
|
474
903
|
for (const pk of entityInfo.PrimaryKeys) {
|
|
475
904
|
relatedRecord.primaryKey[pk.Name] = entity.Get(pk.Name);
|
|
476
905
|
}
|
|
906
|
+
// Track the new related entity now that we have its primary key
|
|
907
|
+
const relatedFilePath = parentFilePath || path_1.default.join(baseDir, 'unknown');
|
|
908
|
+
this.checkAndTrackRecord(entityName, entity, relatedFilePath, parentArrayIndex);
|
|
477
909
|
}
|
|
478
910
|
// Always update sync metadata
|
|
479
911
|
relatedRecord.sync = {
|
|
@@ -483,7 +915,11 @@ class Push extends core_1.Command {
|
|
|
483
915
|
}
|
|
484
916
|
// Process nested related entities if any
|
|
485
917
|
if (relatedRecord.relatedEntities) {
|
|
486
|
-
await this.processRelatedEntities(relatedRecord.relatedEntities, entity, rootEntity, baseDir, syncEngine, verbose, indentLevel + 1);
|
|
918
|
+
const nestedStats = await this.processRelatedEntities(relatedRecord.relatedEntities, entity, rootEntity, baseDir, syncEngine, verbose, fileBackupManager, indentLevel + 1, parentFilePath, parentArrayIndex);
|
|
919
|
+
// Accumulate nested stats
|
|
920
|
+
stats.created += nestedStats.created;
|
|
921
|
+
stats.updated += nestedStats.updated;
|
|
922
|
+
stats.unchanged += nestedStats.unchanged;
|
|
487
923
|
}
|
|
488
924
|
}
|
|
489
925
|
catch (error) {
|
|
@@ -491,6 +927,114 @@ class Push extends core_1.Command {
|
|
|
491
927
|
}
|
|
492
928
|
}
|
|
493
929
|
}
|
|
930
|
+
return stats;
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* Generate a unique tracking key for a record based on entity name and primary key values
|
|
934
|
+
*/
|
|
935
|
+
generateRecordKey(entityName, entity) {
|
|
936
|
+
const entityInfo = entity.EntityInfo;
|
|
937
|
+
const primaryKeyValues = [];
|
|
938
|
+
if (entityInfo && entityInfo.PrimaryKeys.length > 0) {
|
|
939
|
+
for (const pk of entityInfo.PrimaryKeys) {
|
|
940
|
+
const value = entity.Get(pk.Name);
|
|
941
|
+
primaryKeyValues.push(`${pk.Name}:${value}`);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
return `${entityName}|${primaryKeyValues.join('|')}`;
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* Check if a record has already been processed and warn if duplicate
|
|
948
|
+
*/
|
|
949
|
+
checkAndTrackRecord(entityName, entity, filePath, arrayIndex, lineNumber) {
|
|
950
|
+
const recordKey = this.generateRecordKey(entityName, entity);
|
|
951
|
+
const existing = this.processedRecords.get(recordKey);
|
|
952
|
+
if (existing) {
|
|
953
|
+
const primaryKeyDisplay = entity.EntityInfo?.PrimaryKeys
|
|
954
|
+
.map(pk => `${pk.Name}: ${entity.Get(pk.Name)}`)
|
|
955
|
+
.join(', ') || 'unknown';
|
|
956
|
+
// Format file location with clickable link for VSCode
|
|
957
|
+
// Create maps with just the line numbers we have
|
|
958
|
+
const currentLineMap = lineNumber ? new Map([[arrayIndex || 0, lineNumber]]) : undefined;
|
|
959
|
+
const originalLineMap = existing.lineNumber ? new Map([[existing.arrayIndex || 0, existing.lineNumber]]) : undefined;
|
|
960
|
+
const currentLocation = this.formatFileLocation(filePath, arrayIndex, currentLineMap);
|
|
961
|
+
const originalLocation = this.formatFileLocation(existing.filePath, existing.arrayIndex, originalLineMap);
|
|
962
|
+
this.warn(`⚠️ Duplicate record detected for ${entityName} (${primaryKeyDisplay})`);
|
|
963
|
+
this.warn(` Current location: ${currentLocation}`);
|
|
964
|
+
this.warn(` Original location: ${originalLocation}`);
|
|
965
|
+
this.warn(` The duplicate update will proceed, but you should review your data for unintended duplicates.`);
|
|
966
|
+
return true; // is duplicate
|
|
967
|
+
}
|
|
968
|
+
// Track the record with its source location
|
|
969
|
+
this.processedRecords.set(recordKey, {
|
|
970
|
+
filePath: filePath || 'unknown',
|
|
971
|
+
arrayIndex,
|
|
972
|
+
lineNumber
|
|
973
|
+
});
|
|
974
|
+
return false; // not duplicate
|
|
975
|
+
}
|
|
976
|
+
/**
|
|
977
|
+
* Parse JSON file and track line numbers for array elements
|
|
978
|
+
*/
|
|
979
|
+
async parseJsonWithLineNumbers(filePath) {
|
|
980
|
+
const fileText = await fs_extra_1.default.readFile(filePath, 'utf-8');
|
|
981
|
+
const lines = fileText.split('\n');
|
|
982
|
+
const lineNumbers = new Map();
|
|
983
|
+
// Parse the JSON
|
|
984
|
+
const content = JSON.parse(fileText);
|
|
985
|
+
// If it's an array, try to find where each element starts
|
|
986
|
+
if (Array.isArray(content)) {
|
|
987
|
+
let inString = false;
|
|
988
|
+
let bracketDepth = 0;
|
|
989
|
+
let currentIndex = -1;
|
|
990
|
+
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
|
|
991
|
+
const line = lines[lineNum];
|
|
992
|
+
// Simple tracking of string boundaries and bracket depth
|
|
993
|
+
for (let i = 0; i < line.length; i++) {
|
|
994
|
+
const char = line[i];
|
|
995
|
+
const prevChar = i > 0 ? line[i - 1] : '';
|
|
996
|
+
if (char === '"' && prevChar !== '\\') {
|
|
997
|
+
inString = !inString;
|
|
998
|
+
}
|
|
999
|
+
if (!inString) {
|
|
1000
|
+
if (char === '{') {
|
|
1001
|
+
bracketDepth++;
|
|
1002
|
+
// If we're at depth 1 in the main array, this is a new object
|
|
1003
|
+
if (bracketDepth === 1 && line.trim().startsWith('{')) {
|
|
1004
|
+
currentIndex++;
|
|
1005
|
+
lineNumbers.set(currentIndex, lineNum + 1); // 1-based line numbers
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
else if (char === '}') {
|
|
1009
|
+
bracketDepth--;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
return { content, lineNumbers };
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Format file location with clickable link for VSCode
|
|
1019
|
+
*/
|
|
1020
|
+
formatFileLocation(filePath, arrayIndex, lineNumbers) {
|
|
1021
|
+
if (!filePath || filePath === 'unknown') {
|
|
1022
|
+
return 'unknown';
|
|
1023
|
+
}
|
|
1024
|
+
// Get absolute path for better VSCode integration
|
|
1025
|
+
const absolutePath = path_1.default.resolve(filePath);
|
|
1026
|
+
// Try to get actual line number from our tracking
|
|
1027
|
+
let lineNumber = 1;
|
|
1028
|
+
if (arrayIndex !== undefined && lineNumbers && lineNumbers.has(arrayIndex)) {
|
|
1029
|
+
lineNumber = lineNumbers.get(arrayIndex);
|
|
1030
|
+
}
|
|
1031
|
+
else if (arrayIndex !== undefined) {
|
|
1032
|
+
// Fallback estimation if we don't have actual line numbers
|
|
1033
|
+
lineNumber = 2 + (arrayIndex * 15);
|
|
1034
|
+
}
|
|
1035
|
+
// Create clickable file path for VSCode - format: file:line
|
|
1036
|
+
// VSCode will make this clickable in the terminal
|
|
1037
|
+
return `${absolutePath}:${lineNumber}`;
|
|
494
1038
|
}
|
|
495
1039
|
}
|
|
496
1040
|
exports.default = Push;
|