retold-data-service 2.0.21 → 2.0.23

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/.quackage-comprehension-loader.json +19 -0
  2. package/bin/retold-data-service-clone.js +4 -1
  3. package/generate-bookstore-comprehension.js +645 -0
  4. package/package.json +7 -7
  5. package/source/Retold-Data-Service.js +30 -2
  6. package/source/services/comprehension-loader/ComprehensionLoader-Command-Load.js +345 -0
  7. package/source/services/comprehension-loader/ComprehensionLoader-Command-Schema.js +97 -0
  8. package/source/services/comprehension-loader/ComprehensionLoader-Command-Session.js +221 -0
  9. package/source/services/comprehension-loader/ComprehensionLoader-Command-WebUI.js +57 -0
  10. package/source/services/comprehension-loader/Retold-Data-Service-ComprehensionLoader.js +536 -0
  11. package/source/services/comprehension-loader/pict-app/Pict-Application-ComprehensionLoader-Configuration.json +9 -0
  12. package/source/services/comprehension-loader/pict-app/Pict-Application-ComprehensionLoader.js +86 -0
  13. package/source/services/comprehension-loader/pict-app/Pict-ComprehensionLoader-Bundle.js +6 -0
  14. package/source/services/comprehension-loader/pict-app/providers/Pict-Provider-ComprehensionLoader.js +760 -0
  15. package/source/services/comprehension-loader/pict-app/views/PictView-ComprehensionLoader-Layout.js +360 -0
  16. package/source/services/comprehension-loader/pict-app/views/PictView-ComprehensionLoader-Load.js +472 -0
  17. package/source/services/comprehension-loader/pict-app/views/PictView-ComprehensionLoader-Schema.js +119 -0
  18. package/source/services/comprehension-loader/pict-app/views/PictView-ComprehensionLoader-Session.js +269 -0
  19. package/source/services/comprehension-loader/pict-app/views/PictView-ComprehensionLoader-Source.js +330 -0
  20. package/source/services/comprehension-loader/web/comprehension-loader.js +6794 -0
  21. package/source/services/comprehension-loader/web/comprehension-loader.js.map +1 -0
  22. package/source/services/comprehension-loader/web/comprehension-loader.min.js +2 -0
  23. package/source/services/comprehension-loader/web/comprehension-loader.min.js.map +1 -0
  24. package/source/services/comprehension-loader/web/index.html +17 -0
  25. package/source/services/data-cloner/DataCloner-Command-Schema.js +407 -15
  26. package/source/services/data-cloner/Retold-Data-Service-DataCloner.js +59 -1
  27. package/source/services/data-cloner/pict-app/Pict-Application-DataCloner.js +1 -0
  28. package/source/services/data-cloner/pict-app/providers/Pict-Provider-DataCloner.js +125 -5
  29. package/source/services/data-cloner/pict-app/views/PictView-DataCloner-Connection.js +18 -8
  30. package/source/services/data-cloner/pict-app/views/PictView-DataCloner-Deploy.js +104 -1
  31. package/source/services/data-cloner/pict-app/views/PictView-DataCloner-Export.js +1 -1
  32. package/source/services/data-cloner/pict-app/views/PictView-DataCloner-Layout.js +12 -0
  33. package/source/services/data-cloner/web/data-cloner.js +201 -139
  34. package/source/services/data-cloner/web/data-cloner.js.map +1 -1
  35. package/source/services/data-cloner/web/data-cloner.min.js +1 -1
  36. package/source/services/data-cloner/web/data-cloner.min.js.map +1 -1
  37. package/test/RetoldDataService_tests.js +225 -0
@@ -0,0 +1,17 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Retold Comprehension Loader</title>
7
+ <style id="PICT-CSS"></style>
8
+ </head>
9
+ <body>
10
+ <div id="ComprehensionLoader-Application-Container"></div>
11
+ <script src="pict.min.js"></script>
12
+ <script src="comprehension-loader.js"></script>
13
+ <script>
14
+ Pict.safeLoadPictApplication(ComprehensionLoaderApplication, 2);
15
+ </script>
16
+ </body>
17
+ </html>
@@ -15,6 +15,7 @@ module.exports = (pDataClonerService, pOratorServiceServer) =>
15
15
  let tmpPrefix = pDataClonerService.routePrefix;
16
16
 
17
17
  let libFs = require('fs');
18
+ let _ProviderRegistry = require('./DataCloner-ProviderRegistry.js');
18
19
 
19
20
  // POST /clone/schema/fetch
20
21
  // Accepts either:
@@ -318,12 +319,16 @@ module.exports = (pDataClonerService, pOratorServiceServer) =>
318
319
  tmpFable.ProgramConfiguration = {};
319
320
  }
320
321
 
321
- tmpFable.serviceManager.instantiateServiceProvider('MeadowSync',
322
+ let tmpMeadowSync = tmpFable.serviceManager.instantiateServiceProvider('MeadowSync',
322
323
  {
323
324
  SyncEntityList: tmpTableNames,
324
325
  PageSize: 100,
325
326
  SyncDeletedRecords: tmpCloneState.SyncDeletedRecords
326
327
  });
328
+ // Ensure the new instance is the default — instantiateServiceProvider
329
+ // only sets the default on the FIRST call for a given service type,
330
+ // so re-deploys would otherwise use the stale MeadowSync.
331
+ tmpFable.serviceManager.setDefaultServiceInstantiation('MeadowSync', tmpMeadowSync.Hash);
327
332
 
328
333
  tmpFable.MeadowSync.loadMeadowSchema(tmpModelObject,
329
334
  (pSyncInitError) =>
@@ -339,30 +344,417 @@ module.exports = (pDataClonerService, pOratorServiceServer) =>
339
344
  // Store the deployed model so sync mode switches can re-create entities
340
345
  tmpCloneState.DeployedModelObject = tmpModelObject;
341
346
 
342
- tmpFable.log.info(`Data Cloner: Loading model for CRUD endpoints...`);
347
+ // ---- Schema migration: detect and apply column deltas ----
348
+ let tmpMigrationResults = [];
343
349
 
344
- // Load the filtered model so CRUD endpoints are available
345
- tmpFable.RetoldDataServiceMeadowEndpoints.loadModel('RemoteClone', tmpModelObject, tmpCloneState.ConnectionProvider,
346
- (pLoadError) =>
350
+ let fFinalizeDeploy = () =>
351
+ {
352
+ tmpFable.log.info(`Data Cloner: Loading model for CRUD endpoints...`);
353
+
354
+ // Load the filtered model so CRUD endpoints are available
355
+ tmpFable.RetoldDataServiceMeadowEndpoints.loadModel('RemoteClone', tmpModelObject, tmpCloneState.ConnectionProvider,
356
+ (pLoadError) =>
357
+ {
358
+ if (pLoadError)
359
+ {
360
+ tmpFable.log.error(`Data Cloner: Model load error: ${pLoadError}`);
361
+ }
362
+ else
363
+ {
364
+ tmpFable.log.info(`Data Cloner: CRUD endpoints available for: [${tmpTableNames.join(', ')}]`);
365
+ }
366
+
367
+ let tmpTotalColumnsAdded = tmpMigrationResults.reduce((pSum, pR) => pSum + pR.ColumnsAdded.length, 0);
368
+ let tmpMessage = `${tmpInitializedEntities.length} / ${tmpTableNames.length} tables deployed.`;
369
+ if (tmpTotalColumnsAdded > 0)
370
+ {
371
+ tmpMessage += ` ${tmpTotalColumnsAdded} column(s) added via schema migration.`;
372
+ }
373
+ tmpMessage += ` meadow-integration sync ready.`;
374
+
375
+ pResponse.send(200,
376
+ {
377
+ Success: true,
378
+ TablesDeployed: tmpTableNames,
379
+ SyncEntities: tmpInitializedEntities,
380
+ MigrationsApplied: tmpMigrationResults,
381
+ Message: tmpMessage
382
+ });
383
+ return fNext();
384
+ });
385
+ };
386
+
387
+ // Resolve the active connection provider for introspection and SQL execution
388
+ let tmpProviderName = tmpCloneState.ConnectionProvider;
389
+ let tmpProviderRegistryEntry = _ProviderRegistry[tmpProviderName];
390
+ let tmpActiveProvider = tmpProviderRegistryEntry ? tmpFable[tmpProviderRegistryEntry.serviceName] : null;
391
+
392
+ if (!tmpFable.RetoldDataServiceMigrationManager || !tmpActiveProvider || !tmpActiveProvider.connected)
393
+ {
394
+ // Migration manager or provider not available — skip migration step
395
+ return fFinalizeDeploy();
396
+ }
397
+
398
+ let tmpMM = tmpFable.RetoldDataServiceMigrationManager;
399
+
400
+ tmpFable.log.info(`Data Cloner: Checking schema deltas for ${tmpTableNames.length} tables against ${tmpProviderName}...`);
401
+
402
+ tmpFable.Utility.eachLimit(tmpTableNames, 1,
403
+ (pTableName, fNextTable) =>
404
+ {
405
+ if (typeof(tmpActiveProvider.introspectTableColumns) !== 'function')
406
+ {
407
+ // Provider does not support introspection — skip
408
+ return fNextTable();
409
+ }
410
+
411
+ tmpActiveProvider.introspectTableColumns(pTableName,
412
+ (pIntrospectError, pActualColumns) =>
413
+ {
414
+ if (pIntrospectError || !Array.isArray(pActualColumns))
415
+ {
416
+ tmpFable.log.warn(`Data Cloner: Could not introspect ${pTableName}: ${pIntrospectError || 'no columns returned'}`);
417
+ return fNextTable();
418
+ }
419
+
420
+ // Build source schema (actual database) in the format SchemaDiff expects
421
+ let tmpSourceSchema = { Tables: [{ TableName: pTableName, Columns: pActualColumns }] };
422
+
423
+ // Build target schema (expected from remote model)
424
+ let tmpTargetSchema = tmpMM.normalizeSchemaForDiff({ Tables: { [pTableName]: tmpModelObject.Tables[pTableName] } });
425
+
426
+ let tmpDiff = tmpMM._schemaDiff.diffSchemas(tmpSourceSchema, tmpTargetSchema);
427
+
428
+ let tmpColumnsAdded = [];
429
+ if (tmpDiff.TablesModified && tmpDiff.TablesModified.length > 0)
430
+ {
431
+ for (let t = 0; t < tmpDiff.TablesModified.length; t++)
432
+ {
433
+ let tmpMod = tmpDiff.TablesModified[t];
434
+ if (Array.isArray(tmpMod.ColumnsAdded))
435
+ {
436
+ for (let c = 0; c < tmpMod.ColumnsAdded.length; c++)
437
+ {
438
+ tmpColumnsAdded.push(tmpMod.ColumnsAdded[c].Column);
439
+ }
440
+ }
441
+ }
442
+ }
443
+
444
+ if (tmpColumnsAdded.length < 1)
445
+ {
446
+ return fNextTable();
447
+ }
448
+
449
+ // Generate provider-appropriate ALTER TABLE statements
450
+ let tmpStatements = tmpMM._migrationGenerator.generateMigrationStatements(tmpDiff, tmpProviderName);
451
+
452
+ tmpFable.log.info(`Data Cloner: Migrating ${pTableName} — adding ${tmpColumnsAdded.length} column(s): [${tmpColumnsAdded.join(', ')}]`);
453
+
454
+ let tmpExecutedStatements = [];
455
+
456
+ // Filter out comment-only lines (e.g. "-- SQLite does not support ALTER COLUMN...")
457
+ // generated by MigrationGenerator for column modifications that can't be
458
+ // applied automatically. We only want to execute actual SQL statements.
459
+ tmpStatements = tmpStatements.filter((pStmt) => !pStmt.trimStart().startsWith('--'));
460
+
461
+ // Execute statements provider-agnostically
462
+ let fExecNext = (pIndex) =>
463
+ {
464
+ if (pIndex >= tmpStatements.length)
465
+ {
466
+ tmpMigrationResults.push(
467
+ {
468
+ Table: pTableName,
469
+ ColumnsAdded: tmpColumnsAdded,
470
+ Statements: tmpExecutedStatements
471
+ });
472
+ return fNextTable();
473
+ }
474
+
475
+ let tmpSQL = tmpStatements[pIndex];
476
+
477
+ // SQLite: synchronous execution via better-sqlite3
478
+ if (tmpProviderName === 'SQLite' && tmpActiveProvider.db)
479
+ {
480
+ try
481
+ {
482
+ tmpActiveProvider.db.exec(tmpSQL);
483
+ tmpFable.log.info(`Data Cloner: Migration applied: ${tmpSQL}`);
484
+ tmpExecutedStatements.push(tmpSQL);
485
+ }
486
+ catch (pExecError)
487
+ {
488
+ tmpFable.log.warn(`Data Cloner: Migration failed: ${tmpSQL} — ${pExecError}`);
489
+ }
490
+ return fExecNext(pIndex + 1);
491
+ }
492
+
493
+ // MySQL / MSSQL / PostgreSQL: async execution via connection pool
494
+ // MySQL pool.query uses callbacks; MSSQL/PostgreSQL pool.query returns a promise.
495
+ if (tmpActiveProvider.pool)
496
+ {
497
+ let tmpQueryResult = tmpActiveProvider.pool.query(tmpSQL,
498
+ (pQueryError) =>
499
+ {
500
+ // Callback-style (MySQL) — only fires if pool.query
501
+ // accepted the callback parameter
502
+ if (pQueryError)
503
+ {
504
+ tmpFable.log.warn(`Data Cloner: Migration failed: ${tmpSQL} — ${pQueryError}`);
505
+ }
506
+ else
507
+ {
508
+ tmpFable.log.info(`Data Cloner: Migration applied: ${tmpSQL}`);
509
+ tmpExecutedStatements.push(tmpSQL);
510
+ }
511
+ return fExecNext(pIndex + 1);
512
+ });
513
+
514
+ // Promise-style (MSSQL / PostgreSQL) — if query returned a thenable,
515
+ // handle it; the callback above will not fire in this case.
516
+ if (tmpQueryResult && typeof(tmpQueryResult.then) === 'function')
517
+ {
518
+ tmpQueryResult
519
+ .then(() =>
520
+ {
521
+ tmpFable.log.info(`Data Cloner: Migration applied: ${tmpSQL}`);
522
+ tmpExecutedStatements.push(tmpSQL);
523
+ return fExecNext(pIndex + 1);
524
+ })
525
+ .catch((pPromiseError) =>
526
+ {
527
+ tmpFable.log.warn(`Data Cloner: Migration failed: ${tmpSQL} — ${pPromiseError}`);
528
+ return fExecNext(pIndex + 1);
529
+ });
530
+ }
531
+ }
532
+ else
533
+ {
534
+ tmpFable.log.warn(`Data Cloner: No execution handle available for ${tmpProviderName}; skipping: ${tmpSQL}`);
535
+ return fExecNext(pIndex + 1);
536
+ }
537
+ };
538
+
539
+ fExecNext(0);
540
+ });
541
+ },
542
+ () =>
347
543
  {
348
- if (pLoadError)
544
+ if (tmpMigrationResults.length > 0)
349
545
  {
350
- tmpFable.log.error(`Data Cloner: Model load error: ${pLoadError}`);
546
+ let tmpTotalCols = tmpMigrationResults.reduce((pSum, pR) => pSum + pR.ColumnsAdded.length, 0);
547
+ tmpFable.log.info(`Data Cloner: Schema migration complete — ${tmpTotalCols} column(s) added across ${tmpMigrationResults.length} table(s).`);
351
548
  }
352
549
  else
353
550
  {
354
- tmpFable.log.info(`Data Cloner: CRUD endpoints available for: [${tmpTableNames.join(', ')}]`);
551
+ tmpFable.log.info(`Data Cloner: Schema migration check complete no deltas detected.`);
552
+ }
553
+ return fFinalizeDeploy();
554
+ });
555
+ });
556
+ });
557
+
558
+ // GET /clone/schema/guid-index-audit — Check all deployed tables for missing GUID indices
559
+ pOratorServiceServer.get(`${tmpPrefix}/schema/guid-index-audit`,
560
+ (pRequest, pResponse, fNext) =>
561
+ {
562
+ if (!tmpCloneState.DeployedModelObject)
563
+ {
564
+ pResponse.send(400, { Success: false, Error: 'No schema deployed. Deploy tables first.' });
565
+ return fNext();
566
+ }
567
+
568
+ let tmpProviderName = tmpCloneState.ConnectionProvider;
569
+ let tmpProviderRegistryEntry = _ProviderRegistry[tmpProviderName];
570
+ let tmpActiveProvider = tmpProviderRegistryEntry ? tmpFable[tmpProviderRegistryEntry.serviceName] : null;
571
+
572
+ if (!tmpActiveProvider || !tmpActiveProvider.connected || typeof(tmpActiveProvider.introspectTableIndices) !== 'function')
573
+ {
574
+ pResponse.send(400, { Success: false, Error: 'No connected provider with introspection support available.' });
575
+ return fNext();
576
+ }
577
+
578
+ let tmpModelTables = tmpCloneState.DeployedModelObject.Tables || {};
579
+ let tmpTableNames = Object.keys(tmpModelTables);
580
+ let tmpResults = [];
581
+ let tmpMissingCount = 0;
582
+
583
+ tmpFable.Utility.eachLimit(tmpTableNames, 1,
584
+ (pTableName, fNextTable) =>
585
+ {
586
+ let tmpTableSchema = tmpModelTables[pTableName];
587
+ let tmpColumns = Array.isArray(tmpTableSchema.Columns) ? tmpTableSchema.Columns : [];
588
+
589
+ // Find GUID columns in the expected schema
590
+ let tmpGUIDColumns = tmpColumns.filter((pCol) => pCol.DataType === 'GUID');
591
+
592
+ if (tmpGUIDColumns.length < 1)
593
+ {
594
+ return fNextTable();
595
+ }
596
+
597
+ tmpActiveProvider.introspectTableIndices(pTableName,
598
+ (pError, pActualIndices) =>
599
+ {
600
+ if (pError)
601
+ {
602
+ tmpFable.log.warn(`GUID Index Audit: Could not introspect indices for ${pTableName}: ${pError}`);
603
+ return fNextTable();
355
604
  }
356
605
 
357
- pResponse.send(200,
606
+ let tmpIndices = Array.isArray(pActualIndices) ? pActualIndices : [];
607
+ let tmpTableResult = { Table: pTableName, GUIDColumns: [] };
608
+
609
+ for (let g = 0; g < tmpGUIDColumns.length; g++)
610
+ {
611
+ let tmpColName = tmpGUIDColumns[g].Column;
612
+ let tmpExpectedIndexName = `AK_M_${tmpColName}`;
613
+
614
+ // Check if any index covers this GUID column
615
+ let tmpMatchingIndex = tmpIndices.find((pIdx) =>
616
+ pIdx.Name === tmpExpectedIndexName ||
617
+ (Array.isArray(pIdx.Columns) && pIdx.Columns.indexOf(tmpColName) > -1));
618
+
619
+ let tmpHasIndex = !!tmpMatchingIndex;
620
+ if (!tmpHasIndex)
358
621
  {
359
- Success: true,
360
- TablesDeployed: tmpTableNames,
361
- SyncEntities: tmpInitializedEntities,
362
- Message: `${tmpInitializedEntities.length} / ${tmpTableNames.length} tables deployed. meadow-integration sync ready.`
363
- });
364
- return fNext();
622
+ tmpMissingCount++;
623
+ }
624
+
625
+ tmpTableResult.GUIDColumns.push(
626
+ {
627
+ Column: tmpColName,
628
+ HasIndex: tmpHasIndex,
629
+ IndexName: tmpMatchingIndex ? tmpMatchingIndex.Name : null
630
+ });
631
+ }
632
+
633
+ tmpResults.push(tmpTableResult);
634
+ return fNextTable();
365
635
  });
636
+ },
637
+ () =>
638
+ {
639
+ pResponse.send(200,
640
+ {
641
+ Success: true,
642
+ Tables: tmpResults,
643
+ MissingCount: tmpMissingCount,
644
+ Message: tmpMissingCount > 0
645
+ ? `${tmpMissingCount} GUID column(s) across ${tmpResults.filter((pR) => pR.GUIDColumns.some((pC) => !pC.HasIndex)).length} table(s) are missing indices.`
646
+ : 'All GUID columns have indices.'
647
+ });
648
+ return fNext();
649
+ });
650
+ });
651
+
652
+ // POST /clone/schema/guid-index-create — Create missing GUID indices
653
+ pOratorServiceServer.post(`${tmpPrefix}/schema/guid-index-create`,
654
+ (pRequest, pResponse, fNext) =>
655
+ {
656
+ if (!tmpCloneState.DeployedModelObject)
657
+ {
658
+ pResponse.send(400, { Success: false, Error: 'No schema deployed. Deploy tables first.' });
659
+ return fNext();
660
+ }
661
+
662
+ let tmpProviderName = tmpCloneState.ConnectionProvider;
663
+ let tmpProviderRegistryEntry = _ProviderRegistry[tmpProviderName];
664
+ let tmpActiveProvider = tmpProviderRegistryEntry ? tmpFable[tmpProviderRegistryEntry.serviceName] : null;
665
+
666
+ if (!tmpActiveProvider || !tmpActiveProvider.connected)
667
+ {
668
+ pResponse.send(400, { Success: false, Error: 'No connected provider available.' });
669
+ return fNext();
670
+ }
671
+
672
+ // The schema provider has generateCreateIndexStatements and createIndex
673
+ let tmpSchemaProvider = tmpActiveProvider.schemaProvider || tmpActiveProvider;
674
+
675
+ if (typeof(tmpSchemaProvider.generateCreateIndexStatements) !== 'function' ||
676
+ typeof(tmpSchemaProvider.createIndex) !== 'function')
677
+ {
678
+ pResponse.send(400, { Success: false, Error: `Provider ${tmpProviderName} does not support index creation.` });
679
+ return fNext();
680
+ }
681
+
682
+ let tmpBody = pRequest.body || {};
683
+ let tmpFilterTables = Array.isArray(tmpBody.Tables) && tmpBody.Tables.length > 0 ? tmpBody.Tables : null;
684
+
685
+ let tmpModelTables = tmpCloneState.DeployedModelObject.Tables || {};
686
+ let tmpTableNames = tmpFilterTables || Object.keys(tmpModelTables);
687
+ let tmpCreatedIndices = [];
688
+
689
+ tmpFable.log.info(`Data Cloner: Creating missing GUID indices for ${tmpTableNames.length} tables...`);
690
+
691
+ tmpFable.Utility.eachLimit(tmpTableNames, 1,
692
+ (pTableName, fNextTable) =>
693
+ {
694
+ let tmpTableSchema = tmpModelTables[pTableName];
695
+ if (!tmpTableSchema)
696
+ {
697
+ return fNextTable();
698
+ }
699
+
700
+ // Generate all index statements for this table (provider-specific)
701
+ let tmpAllStatements = tmpSchemaProvider.generateCreateIndexStatements(tmpTableSchema);
702
+
703
+ // Filter to GUID indices only (AK_M_GUID*)
704
+ let tmpGUIDStatements = tmpAllStatements.filter((pStmt) => pStmt.Name && pStmt.Name.indexOf('AK_M_GUID') === 0);
705
+
706
+ if (tmpGUIDStatements.length < 1)
707
+ {
708
+ return fNextTable();
709
+ }
710
+
711
+ // createIndex is idempotent on all providers — safe to call even if index exists
712
+ let fCreateNext = (pIndex) =>
713
+ {
714
+ if (pIndex >= tmpGUIDStatements.length)
715
+ {
716
+ return fNextTable();
717
+ }
718
+
719
+ let tmpStmt = tmpGUIDStatements[pIndex];
720
+ tmpSchemaProvider.createIndex(tmpStmt,
721
+ (pCreateError) =>
722
+ {
723
+ if (pCreateError)
724
+ {
725
+ tmpFable.log.warn(`Data Cloner: Failed to create index ${tmpStmt.Name} on ${pTableName}: ${pCreateError}`);
726
+ }
727
+ else
728
+ {
729
+ tmpFable.log.info(`Data Cloner: Created index ${tmpStmt.Name} on ${pTableName}`);
730
+ tmpCreatedIndices.push(
731
+ {
732
+ Table: pTableName,
733
+ IndexName: tmpStmt.Name,
734
+ Statement: tmpStmt.Statement
735
+ });
736
+ }
737
+ return fCreateNext(pIndex + 1);
738
+ });
739
+ };
740
+
741
+ fCreateNext(0);
742
+ },
743
+ () =>
744
+ {
745
+ let tmpMessage = tmpCreatedIndices.length > 0
746
+ ? `${tmpCreatedIndices.length} GUID index(es) created.`
747
+ : 'No new GUID indices were needed.';
748
+
749
+ tmpFable.log.info(`Data Cloner: ${tmpMessage}`);
750
+
751
+ pResponse.send(200,
752
+ {
753
+ Success: true,
754
+ IndicesCreated: tmpCreatedIndices,
755
+ Message: tmpMessage
756
+ });
757
+ return fNext();
366
758
  });
367
759
  });
368
760
  };
@@ -164,6 +164,30 @@ class RetoldDataServiceDataCloner extends libFableServiceProviderBase
164
164
  // and mysql2-native (host, port, user, etc.) property names so it works
165
165
  // with any version of the meadow-connection provider.
166
166
  let tmpNormalizedConfig = Object.assign({}, pConfig);
167
+
168
+ // Expand a leading ~ to the user's home directory for file-based providers.
169
+ // Node.js does not expand ~ like the shell does, so paths such as
170
+ // "~/my-data/cloned.sqlite" would otherwise be treated as a literal
171
+ // relative path, creating the file in an unexpected location.
172
+ if (tmpNormalizedConfig.SQLiteFilePath && typeof(tmpNormalizedConfig.SQLiteFilePath) === 'string'
173
+ && tmpNormalizedConfig.SQLiteFilePath.startsWith('~'))
174
+ {
175
+ const libOs = require('os');
176
+ const libPath = require('path');
177
+ const libFs = require('fs');
178
+ tmpNormalizedConfig.SQLiteFilePath = libPath.resolve(
179
+ libOs.homedir(),
180
+ tmpNormalizedConfig.SQLiteFilePath.slice(tmpNormalizedConfig.SQLiteFilePath.startsWith('~/') ? 2 : 1)
181
+ );
182
+ // Ensure the parent directory exists — SQLite will create the file
183
+ // but not intermediate directories.
184
+ let tmpDir = libPath.dirname(tmpNormalizedConfig.SQLiteFilePath);
185
+ if (!libFs.existsSync(tmpDir))
186
+ {
187
+ libFs.mkdirSync(tmpDir, { recursive: true });
188
+ }
189
+ }
190
+
167
191
  if (pProviderName === 'MySQL' || pProviderName === 'MSSQL' || pProviderName === 'PostgreSQL')
168
192
  {
169
193
  if (tmpNormalizedConfig.host && !tmpNormalizedConfig.Server)
@@ -203,9 +227,43 @@ class RetoldDataServiceDataCloner extends libFableServiceProviderBase
203
227
  }
204
228
  else
205
229
  {
230
+ let tmpExistingProvider = this.fable[tmpRegistryEntry.serviceName];
231
+
232
+ // Check if the config actually changed — skip reconnect if identical
233
+ let tmpCurrentPath = tmpExistingProvider.options.SQLiteFilePath || '';
234
+ let tmpNewPath = tmpNormalizedConfig.SQLiteFilePath || '';
235
+ let tmpConfigChanged = (pProviderName === 'SQLite')
236
+ ? (tmpNewPath !== tmpCurrentPath)
237
+ : (JSON.stringify(tmpNormalizedConfig) !== JSON.stringify(this._cloneState.ConnectionConfig || {}));
238
+
206
239
  // Provider already exists — update its options with the new config
207
240
  // so reconnects use the current settings.
208
- this.fable[tmpRegistryEntry.serviceName].options[tmpRegistryEntry.configKey] = tmpNormalizedConfig;
241
+ tmpExistingProvider.options[tmpRegistryEntry.configKey] = tmpNormalizedConfig;
242
+
243
+ if (tmpConfigChanged)
244
+ {
245
+ // For SQLite, connectAsync reads SQLiteFilePath directly from
246
+ // this.options (set once in the constructor from fable.settings).
247
+ // Merge the normalized config onto options so the provider sees
248
+ // the updated path without needing a new instance.
249
+ if (pProviderName === 'SQLite')
250
+ {
251
+ Object.assign(tmpExistingProvider.options, tmpNormalizedConfig);
252
+ }
253
+
254
+ // Allow re-connection with updated settings by resetting the
255
+ // connected flag — connectAsync short-circuits when already connected.
256
+ tmpExistingProvider.connected = false;
257
+ }
258
+ else if (tmpExistingProvider.connected)
259
+ {
260
+ // Config unchanged and already connected — skip the connectAsync call
261
+ // entirely to avoid log spam from repeated auto-connect calls.
262
+ this._cloneState.ConnectionProvider = pProviderName;
263
+ this._cloneState.ConnectionConnected = true;
264
+ this._cloneState.ConnectionConfig = pConfig;
265
+ return fCallback();
266
+ }
209
267
  }
210
268
 
211
269
  this.fable[tmpRegistryEntry.serviceName].connectAsync(
@@ -59,6 +59,7 @@ class DataClonerApplication extends libPictApplication
59
59
  SyncPollTimer: null,
60
60
  LiveStatusTimer: null,
61
61
  StatusDetailExpanded: false,
62
+ StatusDetailAutoExpanded: false,
62
63
  StatusDetailTimer: null,
63
64
  StatusDetailData: null,
64
65
  LastLiveStatus: null,