@memberjunction/metadata-sync 2.51.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/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/push/index.d.ts +17 -0
- package/dist/commands/push/index.js +407 -37
- package/dist/commands/push/index.js.map +1 -1
- 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/sync-engine.js +2 -1
- package/dist/lib/sync-engine.js.map +1 -1
- package/dist/services/FormattingService.d.ts +1 -0
- package/dist/services/FormattingService.js +4 -1
- package/dist/services/FormattingService.js.map +1 -1
- package/oclif.manifest.json +114 -45
- package/package.json +7 -7
|
@@ -38,11 +38,14 @@ const provider_utils_1 = require("../../lib/provider-utils");
|
|
|
38
38
|
const core_2 = require("@memberjunction/core");
|
|
39
39
|
const config_manager_1 = require("../../lib/config-manager");
|
|
40
40
|
const singleton_manager_1 = require("../../lib/singleton-manager");
|
|
41
|
+
const sqlserver_dataprovider_1 = require("@memberjunction/sqlserver-dataprovider");
|
|
41
42
|
const global_1 = require("@memberjunction/global");
|
|
43
|
+
const file_backup_manager_1 = require("../../lib/file-backup-manager");
|
|
42
44
|
class Push extends core_1.Command {
|
|
43
45
|
static description = 'Push local file changes to the database';
|
|
44
46
|
warnings = [];
|
|
45
47
|
errors = [];
|
|
48
|
+
processedRecords = new Map();
|
|
46
49
|
static examples = [
|
|
47
50
|
`<%= config.bin %> <%= command.id %>`,
|
|
48
51
|
`<%= config.bin %> <%= command.id %> --dry-run`,
|
|
@@ -66,7 +69,11 @@ class Push extends core_1.Command {
|
|
|
66
69
|
const { flags } = await this.parse(Push);
|
|
67
70
|
const spinner = (0, ora_classic_1.default)();
|
|
68
71
|
let sqlLogger = null;
|
|
72
|
+
const fileBackupManager = new file_backup_manager_1.FileBackupManager();
|
|
73
|
+
let hasActiveTransaction = false;
|
|
69
74
|
const startTime = Date.now();
|
|
75
|
+
// Reset the processed records tracking for this push operation
|
|
76
|
+
this.processedRecords.clear();
|
|
70
77
|
try {
|
|
71
78
|
// Load configurations
|
|
72
79
|
spinner.start('Loading configuration');
|
|
@@ -169,31 +176,69 @@ class Push extends core_1.Command {
|
|
|
169
176
|
this.log(chalk_1.default.green('✓ Validation passed'));
|
|
170
177
|
}
|
|
171
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
|
+
}
|
|
172
186
|
// Start a database transaction for the entire push operation (unless in dry-run mode)
|
|
173
187
|
// IMPORTANT: We start the transaction AFTER metadata loading and validation to avoid
|
|
174
188
|
// transaction conflicts with background refresh operations
|
|
175
189
|
if (!flags['dry-run']) {
|
|
176
190
|
const { getDataProvider } = await Promise.resolve().then(() => __importStar(require('../../lib/provider-utils')));
|
|
177
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
|
+
}
|
|
178
204
|
if (dataProvider && typeof dataProvider.BeginTransaction === 'function') {
|
|
179
205
|
try {
|
|
180
206
|
await dataProvider.BeginTransaction();
|
|
207
|
+
hasActiveTransaction = true;
|
|
181
208
|
if (flags.verbose) {
|
|
182
209
|
this.log('🔄 Transaction started - all changes will be committed or rolled back as a unit');
|
|
183
210
|
}
|
|
184
211
|
}
|
|
185
212
|
catch (error) {
|
|
186
|
-
|
|
187
|
-
|
|
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);
|
|
188
223
|
}
|
|
189
224
|
}
|
|
190
225
|
else {
|
|
191
|
-
|
|
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);
|
|
192
236
|
}
|
|
193
237
|
}
|
|
194
238
|
// Process each entity directory
|
|
195
239
|
let totalCreated = 0;
|
|
196
240
|
let totalUpdated = 0;
|
|
241
|
+
let totalUnchanged = 0;
|
|
197
242
|
let totalErrors = 0;
|
|
198
243
|
for (const entityDir of entityDirs) {
|
|
199
244
|
const entityConfig = await (0, config_1.loadEntityConfig)(entityDir);
|
|
@@ -204,9 +249,29 @@ class Push extends core_1.Command {
|
|
|
204
249
|
if (flags.verbose) {
|
|
205
250
|
this.log(`\nProcessing ${entityConfig.entity} in ${entityDir}`);
|
|
206
251
|
}
|
|
207
|
-
const result = await this.processEntityDirectory(entityDir, entityConfig, syncEngine, flags, syncConfig);
|
|
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
|
+
}
|
|
208
272
|
totalCreated += result.created;
|
|
209
273
|
totalUpdated += result.updated;
|
|
274
|
+
totalUnchanged += result.unchanged;
|
|
210
275
|
totalErrors += result.errors;
|
|
211
276
|
}
|
|
212
277
|
// Summary using FormattingService
|
|
@@ -216,15 +281,16 @@ class Push extends core_1.Command {
|
|
|
216
281
|
this.log('\n' + formatter.formatSyncSummary('push', {
|
|
217
282
|
created: totalCreated,
|
|
218
283
|
updated: totalUpdated,
|
|
284
|
+
unchanged: totalUnchanged,
|
|
219
285
|
deleted: 0,
|
|
220
286
|
skipped: 0,
|
|
221
287
|
errors: totalErrors,
|
|
222
288
|
duration: endTime - startTime
|
|
223
289
|
}));
|
|
224
290
|
// Handle transaction commit/rollback
|
|
225
|
-
if (!flags['dry-run']) {
|
|
291
|
+
if (!flags['dry-run'] && hasActiveTransaction) {
|
|
226
292
|
const dataProvider = core_2.Metadata.Provider;
|
|
227
|
-
//
|
|
293
|
+
// We know we have an active transaction at this point
|
|
228
294
|
if (dataProvider) {
|
|
229
295
|
let shouldCommit = true;
|
|
230
296
|
// If there are any errors, always rollback
|
|
@@ -261,12 +327,18 @@ class Push extends core_1.Command {
|
|
|
261
327
|
if (shouldCommit) {
|
|
262
328
|
await dataProvider.CommitTransaction();
|
|
263
329
|
this.log('\n✅ All changes committed successfully');
|
|
330
|
+
// Clean up file backups after successful commit
|
|
331
|
+
await fileBackupManager.cleanup();
|
|
264
332
|
}
|
|
265
333
|
else {
|
|
266
334
|
// User chose to rollback or errors/warnings in CI mode
|
|
267
335
|
this.log('\n🔙 Rolling back all changes...');
|
|
336
|
+
// Rollback database transaction
|
|
268
337
|
await dataProvider.RollbackTransaction();
|
|
269
|
-
|
|
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');
|
|
270
342
|
}
|
|
271
343
|
}
|
|
272
344
|
catch (error) {
|
|
@@ -274,10 +346,19 @@ class Push extends core_1.Command {
|
|
|
274
346
|
this.log('\n❌ Transaction error - attempting to roll back changes');
|
|
275
347
|
try {
|
|
276
348
|
await dataProvider.RollbackTransaction();
|
|
277
|
-
this.log('✅
|
|
349
|
+
this.log('✅ Database rollback completed');
|
|
278
350
|
}
|
|
279
351
|
catch (rollbackError) {
|
|
280
|
-
this.log('❌
|
|
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)));
|
|
281
362
|
}
|
|
282
363
|
throw error;
|
|
283
364
|
}
|
|
@@ -290,20 +371,30 @@ class Push extends core_1.Command {
|
|
|
290
371
|
}
|
|
291
372
|
catch (error) {
|
|
292
373
|
spinner.fail('Push failed');
|
|
293
|
-
// Try to rollback the transaction if
|
|
374
|
+
// Try to rollback the transaction and files if not in dry-run mode
|
|
294
375
|
if (!flags['dry-run']) {
|
|
295
376
|
const { getDataProvider } = await Promise.resolve().then(() => __importStar(require('../../lib/provider-utils')));
|
|
296
377
|
const dataProvider = getDataProvider();
|
|
297
|
-
|
|
378
|
+
// Rollback database transaction if we have one
|
|
379
|
+
if (hasActiveTransaction && dataProvider && typeof dataProvider.RollbackTransaction === 'function') {
|
|
298
380
|
try {
|
|
299
|
-
this.log('\n🔙 Rolling back transaction due to error...');
|
|
381
|
+
this.log('\n🔙 Rolling back database transaction due to error...');
|
|
300
382
|
await dataProvider.RollbackTransaction();
|
|
301
|
-
this.log('✅
|
|
383
|
+
this.log('✅ Database rollback completed');
|
|
302
384
|
}
|
|
303
385
|
catch (rollbackError) {
|
|
304
|
-
this.log('❌
|
|
386
|
+
this.log('❌ Database rollback failed: ' + (rollbackError instanceof Error ? rollbackError.message : String(rollbackError)));
|
|
305
387
|
}
|
|
306
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
|
+
}
|
|
307
398
|
}
|
|
308
399
|
// Enhanced error logging for debugging
|
|
309
400
|
this.log('\n=== Push Error Details ===');
|
|
@@ -353,8 +444,8 @@ class Push extends core_1.Command {
|
|
|
353
444
|
process.exit(0);
|
|
354
445
|
}
|
|
355
446
|
}
|
|
356
|
-
async processEntityDirectory(entityDir, entityConfig, syncEngine, flags, syncConfig) {
|
|
357
|
-
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 };
|
|
358
449
|
// Find files matching the configured pattern
|
|
359
450
|
const pattern = entityConfig.filePattern || '*.json';
|
|
360
451
|
const jsonFiles = await (0, fast_glob_1.default)(pattern, {
|
|
@@ -367,7 +458,7 @@ class Push extends core_1.Command {
|
|
|
367
458
|
this.log(`Processing ${jsonFiles.length} records in ${path_1.default.relative(process.cwd(), entityDir) || '.'}`);
|
|
368
459
|
}
|
|
369
460
|
// First, process all JSON files in this directory
|
|
370
|
-
await this.processJsonFiles(jsonFiles, entityDir, entityConfig, syncEngine, flags, result);
|
|
461
|
+
await this.processJsonFiles(jsonFiles, entityDir, entityConfig, syncEngine, flags, result, fileBackupManager);
|
|
371
462
|
// Then, recursively process subdirectories
|
|
372
463
|
const entries = await fs_extra_1.default.readdir(entityDir, { withFileTypes: true });
|
|
373
464
|
for (const entry of entries) {
|
|
@@ -393,47 +484,68 @@ class Push extends core_1.Command {
|
|
|
393
484
|
};
|
|
394
485
|
}
|
|
395
486
|
// Process subdirectory with merged config
|
|
396
|
-
const subResult = await this.processEntityDirectory(subDir, subEntityConfig, syncEngine, flags, syncConfig);
|
|
487
|
+
const subResult = await this.processEntityDirectory(subDir, subEntityConfig, syncEngine, flags, syncConfig, fileBackupManager);
|
|
397
488
|
result.created += subResult.created;
|
|
398
489
|
result.updated += subResult.updated;
|
|
490
|
+
result.unchanged += subResult.unchanged;
|
|
399
491
|
result.errors += subResult.errors;
|
|
400
492
|
}
|
|
401
493
|
}
|
|
402
494
|
return result;
|
|
403
495
|
}
|
|
404
|
-
async processJsonFiles(jsonFiles, entityDir, entityConfig, syncEngine, flags, result) {
|
|
496
|
+
async processJsonFiles(jsonFiles, entityDir, entityConfig, syncEngine, flags, result, fileBackupManager) {
|
|
405
497
|
if (jsonFiles.length === 0) {
|
|
406
498
|
return;
|
|
407
499
|
}
|
|
408
500
|
const spinner = (0, ora_classic_1.default)();
|
|
409
501
|
spinner.start('Processing records');
|
|
410
|
-
let totalRecords = 0;
|
|
411
502
|
for (const file of jsonFiles) {
|
|
412
503
|
try {
|
|
413
504
|
const filePath = path_1.default.join(entityDir, file);
|
|
414
|
-
|
|
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);
|
|
415
511
|
// Process templates in the loaded content
|
|
416
512
|
const processedContent = await syncEngine.processTemplates(fileContent, entityDir);
|
|
417
513
|
// Check if the file contains a single record or an array of records
|
|
418
514
|
const isArray = Array.isArray(processedContent);
|
|
419
515
|
const records = isArray ? processedContent : [processedContent];
|
|
420
|
-
totalRecords += records.length;
|
|
421
516
|
// Build and process defaults (including lookups)
|
|
422
517
|
const defaults = await syncEngine.buildDefaults(filePath, entityConfig);
|
|
423
518
|
// Process each record in the file
|
|
424
519
|
for (let i = 0; i < records.length; i++) {
|
|
425
520
|
const recordData = records[i];
|
|
426
521
|
// Process the record
|
|
427
|
-
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);
|
|
428
524
|
if (!flags['dry-run']) {
|
|
429
|
-
|
|
430
|
-
|
|
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
|
+
}
|
|
431
536
|
}
|
|
432
|
-
|
|
433
|
-
|
|
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
|
+
}
|
|
434
546
|
}
|
|
435
547
|
}
|
|
436
|
-
spinner.text = `Processing records (${result.created + result.updated + result.errors}
|
|
548
|
+
spinner.text = `Processing records (${result.created + result.updated + result.unchanged + result.errors} processed)`;
|
|
437
549
|
}
|
|
438
550
|
// Write back the entire file if it's an array
|
|
439
551
|
if (isArray && !flags['dry-run']) {
|
|
@@ -449,18 +561,39 @@ class Push extends core_1.Command {
|
|
|
449
561
|
}
|
|
450
562
|
}
|
|
451
563
|
if (flags.verbose) {
|
|
452
|
-
spinner.succeed(`Processed ${
|
|
564
|
+
spinner.succeed(`Processed ${result.created + result.updated + result.unchanged} records from ${jsonFiles.length} files`);
|
|
453
565
|
}
|
|
454
566
|
else {
|
|
455
567
|
spinner.stop();
|
|
456
568
|
}
|
|
457
569
|
}
|
|
458
|
-
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) {
|
|
459
571
|
// Load or create entity
|
|
460
572
|
let entity = null;
|
|
461
573
|
let isNew = false;
|
|
462
574
|
if (recordData.primaryKey) {
|
|
463
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
|
+
}
|
|
464
597
|
}
|
|
465
598
|
if (!entity) {
|
|
466
599
|
// New record
|
|
@@ -521,7 +654,46 @@ class Push extends core_1.Command {
|
|
|
521
654
|
}
|
|
522
655
|
if (dryRun) {
|
|
523
656
|
this.log(`Would ${isNew ? 'create' : 'update'} ${entityName} record`);
|
|
524
|
-
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;
|
|
525
697
|
}
|
|
526
698
|
// Save the record
|
|
527
699
|
const saved = await entity.Save();
|
|
@@ -534,9 +706,12 @@ class Push extends core_1.Command {
|
|
|
534
706
|
throw new Error(`Failed to save record: ${errors}`);
|
|
535
707
|
}
|
|
536
708
|
// Process related entities after saving parent
|
|
709
|
+
let relatedStats;
|
|
537
710
|
if (recordData.relatedEntities && !dryRun) {
|
|
538
|
-
|
|
539
|
-
|
|
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);
|
|
540
715
|
}
|
|
541
716
|
// Update the local file with new primary key if created
|
|
542
717
|
if (isNew) {
|
|
@@ -548,6 +723,9 @@ class Push extends core_1.Command {
|
|
|
548
723
|
}
|
|
549
724
|
recordData.primaryKey = newPrimaryKey;
|
|
550
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);
|
|
551
729
|
}
|
|
552
730
|
// Always update sync metadata
|
|
553
731
|
// This ensures related entities are persisted with their metadata
|
|
@@ -561,10 +739,11 @@ class Push extends core_1.Command {
|
|
|
561
739
|
const filePath = path_1.default.join(baseDir, fileName);
|
|
562
740
|
await fs_extra_1.default.writeJson(filePath, recordData, { spaces: 2 });
|
|
563
741
|
}
|
|
564
|
-
return isNew;
|
|
742
|
+
return { isNew, wasActuallyUpdated, isDuplicate, relatedStats };
|
|
565
743
|
}
|
|
566
|
-
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) {
|
|
567
745
|
const indent = ' '.repeat(indentLevel);
|
|
746
|
+
const stats = { created: 0, updated: 0, unchanged: 0 };
|
|
568
747
|
for (const [entityName, records] of Object.entries(relatedEntities)) {
|
|
569
748
|
if (verbose) {
|
|
570
749
|
this.log(`${indent}↳ Processing ${records.length} related ${entityName} records`);
|
|
@@ -576,6 +755,27 @@ class Push extends core_1.Command {
|
|
|
576
755
|
let isNew = false;
|
|
577
756
|
if (relatedRecord.primaryKey) {
|
|
578
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
|
+
}
|
|
579
779
|
}
|
|
580
780
|
if (!entity) {
|
|
581
781
|
entity = await syncEngine.createEntityObject(entityName);
|
|
@@ -626,6 +826,46 @@ class Push extends core_1.Command {
|
|
|
626
826
|
this.warn(`${indent} Field '${field}' does not exist on entity '${entityName}'`);
|
|
627
827
|
}
|
|
628
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
|
+
}
|
|
629
869
|
// Save the related entity
|
|
630
870
|
const saved = await entity.Save();
|
|
631
871
|
if (!saved) {
|
|
@@ -636,9 +876,24 @@ class Push extends core_1.Command {
|
|
|
636
876
|
const errors = entity.LatestResult?.Errors?.map(err => typeof err === 'string' ? err : (err?.message || JSON.stringify(err)))?.join(', ') || 'Unknown error';
|
|
637
877
|
throw new Error(`Failed to save related ${entityName}: ${errors}`);
|
|
638
878
|
}
|
|
639
|
-
|
|
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) {
|
|
640
892
|
this.log(`${indent} ✓ ${isNew ? 'Created' : 'Updated'} ${entityName} record`);
|
|
641
893
|
}
|
|
894
|
+
else if (verbose && !wasActuallyUpdated) {
|
|
895
|
+
this.log(`${indent} - No changes to ${entityName} record`);
|
|
896
|
+
}
|
|
642
897
|
// Update the related record with primary key and sync metadata
|
|
643
898
|
const entityInfo = syncEngine.getEntityInfo(entityName);
|
|
644
899
|
if (entityInfo) {
|
|
@@ -648,6 +903,9 @@ class Push extends core_1.Command {
|
|
|
648
903
|
for (const pk of entityInfo.PrimaryKeys) {
|
|
649
904
|
relatedRecord.primaryKey[pk.Name] = entity.Get(pk.Name);
|
|
650
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);
|
|
651
909
|
}
|
|
652
910
|
// Always update sync metadata
|
|
653
911
|
relatedRecord.sync = {
|
|
@@ -657,7 +915,11 @@ class Push extends core_1.Command {
|
|
|
657
915
|
}
|
|
658
916
|
// Process nested related entities if any
|
|
659
917
|
if (relatedRecord.relatedEntities) {
|
|
660
|
-
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;
|
|
661
923
|
}
|
|
662
924
|
}
|
|
663
925
|
catch (error) {
|
|
@@ -665,6 +927,114 @@ class Push extends core_1.Command {
|
|
|
665
927
|
}
|
|
666
928
|
}
|
|
667
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}`;
|
|
668
1038
|
}
|
|
669
1039
|
}
|
|
670
1040
|
exports.default = Push;
|