meadow-integration 1.0.16 → 1.0.18

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "meadow-integration",
3
- "version": "1.0.16",
3
+ "version": "1.0.18",
4
4
  "description": "Meadow Data Integration",
5
5
  "bin": {
6
6
  "mdwint": "source/cli/Meadow-Integration-CLI-Run.js"
@@ -16,7 +16,7 @@
16
16
  "author": "steven velozo <steven@velozo.com>",
17
17
  "license": "MIT",
18
18
  "devDependencies": {
19
- "quackage": "^1.0.63"
19
+ "quackage": "^1.0.64"
20
20
  },
21
21
  "mocha": {
22
22
  "diff": true,
@@ -41,6 +41,7 @@ class MeadowSyncEntityInitial extends libFableServiceProviderBase
41
41
  this.PageSize = this.options.PageSize || 100;
42
42
  this.SyncDeletedRecords = this.options.SyncDeletedRecords || false;
43
43
  this.MaxRecordsPerEntity = this.options.MaxRecordsPerEntity || 0;
44
+ this.UseAdvancedIDPagination = this.options.UseAdvancedIDPagination || false;
44
45
 
45
46
  this.Meadow = false;
46
47
 
@@ -447,9 +448,10 @@ class MeadowSyncEntityInitial extends libFableServiceProviderBase
447
448
  tmpSyncState.EstimatedRecordCount = tmpSyncState.Server.RecordCount - tmpSyncState.Local.RecordCount;
448
449
 
449
450
  // Apply MaxRecordsPerEntity cap if configured
450
- let tmpRecordCap = (this.MaxRecordsPerEntity > 0)
451
+ tmpSyncState.RecordCap = (this.MaxRecordsPerEntity > 0)
451
452
  ? Math.min(tmpSyncState.Server.RecordCount, this.MaxRecordsPerEntity)
452
453
  : tmpSyncState.Server.RecordCount;
454
+ let tmpRecordCap = tmpSyncState.RecordCap;
453
455
 
454
456
  if (this.MaxRecordsPerEntity > 0 && tmpSyncState.EstimatedRecordCount > this.MaxRecordsPerEntity)
455
457
  {
@@ -459,14 +461,21 @@ class MeadowSyncEntityInitial extends libFableServiceProviderBase
459
461
  this.operation.createProgressTracker(tmpSyncState.EstimatedRecordCount, `FullSync-${this.EntitySchema.TableName}`);
460
462
  this.operation.printProgressTrackerStatus(`FullSync-${this.EntitySchema.TableName}`);
461
463
 
462
- // Generate paginated URL partials
463
- tmpSyncState.URLPartials = [];
464
- for (let i = 0; i < tmpRecordCap; i += this.PageSize)
464
+ if (this.UseAdvancedIDPagination)
465
465
  {
466
- tmpSyncState.URLPartials.push(`${this.EntitySchema.TableName}s/FilteredTo/FBV~${this.DefaultIdentifier}~GT~${tmpSyncState.Local.MaxIDEntity}~FSF~${this.DefaultIdentifier}~ASC~ASC/${i}/${this.PageSize}`);
466
+ this.fable.log.info(`${this.EntitySchema.TableName}: using advanced ID pagination (local: ${tmpSyncState.Local.RecordCount}/${tmpSyncState.Local.MaxIDEntity}, server: ${tmpSyncState.Server.RecordCount}/${tmpSyncState.Server.MaxIDEntity}, estimated new: ${tmpSyncState.EstimatedRecordCount}${this.MaxRecordsPerEntity > 0 ? `, capped at ${this.MaxRecordsPerEntity}` : ''})`);
467
467
  }
468
+ else
469
+ {
470
+ // Generate paginated URL partials
471
+ tmpSyncState.URLPartials = [];
472
+ for (let i = 0; i < tmpRecordCap; i += this.PageSize)
473
+ {
474
+ tmpSyncState.URLPartials.push(`${this.EntitySchema.TableName}s/FilteredTo/FBV~${this.DefaultIdentifier}~GT~${tmpSyncState.Local.MaxIDEntity}~FSF~${this.DefaultIdentifier}~ASC~ASC/${i}/${this.PageSize}`);
475
+ }
468
476
 
469
- this.fable.log.info(`${this.EntitySchema.TableName}: downloading ${tmpSyncState.URLPartials.length} pages (local: ${tmpSyncState.Local.RecordCount}/${tmpSyncState.Local.MaxIDEntity}, server: ${tmpSyncState.Server.RecordCount}/${tmpSyncState.Server.MaxIDEntity}, estimated new: ${tmpSyncState.EstimatedRecordCount}${this.MaxRecordsPerEntity > 0 ? `, capped at ${this.MaxRecordsPerEntity}` : ''})`);
477
+ this.fable.log.info(`${this.EntitySchema.TableName}: downloading ${tmpSyncState.URLPartials.length} pages (local: ${tmpSyncState.Local.RecordCount}/${tmpSyncState.Local.MaxIDEntity}, server: ${tmpSyncState.Server.RecordCount}/${tmpSyncState.Server.MaxIDEntity}, estimated new: ${tmpSyncState.EstimatedRecordCount}${this.MaxRecordsPerEntity > 0 ? `, capped at ${this.MaxRecordsPerEntity}` : ''})`);
478
+ }
470
479
 
471
480
  return fStageComplete();
472
481
  },
@@ -477,149 +486,216 @@ class MeadowSyncEntityInitial extends libFableServiceProviderBase
477
486
  let tmpRecordsSkipped = 0;
478
487
  let tmpRecordsErrored = 0;
479
488
 
480
- this.fable.Utility.eachLimit(tmpSyncState.URLPartials, 1,
481
- (pURLPartial, fDownloadComplete) =>
482
- {
483
- tmpPageIndex++;
489
+ // Shared record-processing function used by both pagination modes
490
+ const fProcessPageRecords = (pBody, fPageProcessComplete) =>
491
+ {
492
+ this.fable.Utility.eachLimit(pBody, 5,
493
+ (pEntityRecord, fEntitySyncComplete) =>
494
+ {
495
+ const tmpRecord = pEntityRecord;
496
+ const tmpQuery = this.Meadow.query;
484
497
 
485
- this.fable.MeadowCloneRestClient.getJSON(pURLPartial,
486
- (pDownloadError, pResponse, pBody) =>
498
+ if ((typeof(tmpRecord[this.DefaultIdentifier]) !== 'undefined') && (tmpRecord[this.DefaultIdentifier] > 0))
487
499
  {
488
- if (pDownloadError)
489
- {
490
- this.fable.log.error(`${this.EntitySchema.TableName}: page ${tmpPageIndex} download error: ${pDownloadError}`);
491
- return fDownloadComplete();
492
- }
493
- if (pBody && Array.isArray(pBody) && pBody.length > 0)
494
- {
495
- this.fable.Utility.eachLimit(pBody, 5,
496
- (pEntityRecord, fEntitySyncComplete) =>
497
- {
498
- const tmpRecord = pEntityRecord;
499
- const tmpQuery = this.Meadow.query;
500
-
501
- if ((typeof(tmpRecord[this.DefaultIdentifier]) !== 'undefined') && (tmpRecord[this.DefaultIdentifier] > 0))
502
- {
503
- tmpQuery.addFilter(this.DefaultIdentifier, tmpRecord[this.DefaultIdentifier]);
504
- }
500
+ tmpQuery.addFilter(this.DefaultIdentifier, tmpRecord[this.DefaultIdentifier]);
501
+ }
505
502
 
506
- if (!tmpSyncState.HasDeletedColumn)
507
- {
508
- tmpQuery.setDisableDeleteTracking(true);
509
- }
503
+ if (!tmpSyncState.HasDeletedColumn)
504
+ {
505
+ tmpQuery.setDisableDeleteTracking(true);
506
+ }
510
507
 
511
- this.Meadow.doRead(tmpQuery,
512
- (pReadError, pQuery, pRecord) =>
513
- {
514
- if (pReadError)
515
- {
516
- tmpRecordsErrored++;
517
- return fEntitySyncComplete();
518
- }
519
- if (!pRecord)
520
- {
521
- // Record not found -- create it
522
- const tmpRecordToCommit = this.marshalRecord(tmpRecord);
508
+ this.Meadow.doRead(tmpQuery,
509
+ (pReadError, pQuery, pRecord) =>
510
+ {
511
+ if (pReadError)
512
+ {
513
+ tmpRecordsErrored++;
514
+ return fEntitySyncComplete();
515
+ }
516
+ if (!pRecord)
517
+ {
518
+ // Record not found -- create it
519
+ const tmpRecordToCommit = this.marshalRecord(tmpRecord);
523
520
 
524
- const tmpCreateQuery = this.Meadow.query.addRecord(tmpRecordToCommit);
521
+ const tmpCreateQuery = this.Meadow.query.addRecord(tmpRecordToCommit);
525
522
 
526
- tmpCreateQuery.setDisableAutoIdentity(true);
527
- tmpCreateQuery.setDisableAutoDateStamp(true);
528
- tmpCreateQuery.setDisableAutoUserStamp(true);
529
- tmpCreateQuery.setDisableDeleteTracking(true);
523
+ tmpCreateQuery.setDisableAutoIdentity(true);
524
+ tmpCreateQuery.setDisableAutoDateStamp(true);
525
+ tmpCreateQuery.setDisableAutoUserStamp(true);
526
+ tmpCreateQuery.setDisableDeleteTracking(true);
530
527
 
531
- tmpCreateQuery.AllowIdentityInsert = true;
528
+ tmpCreateQuery.AllowIdentityInsert = true;
532
529
 
533
- this.Meadow.doCreate(tmpCreateQuery,
534
- (pCreateError) =>
530
+ this.Meadow.doCreate(tmpCreateQuery,
531
+ (pCreateError) =>
532
+ {
533
+ if (pCreateError)
534
+ {
535
+ let tmpErrorStr = (typeof(pCreateError) === 'string') ? pCreateError : JSON.stringify(pCreateError);
536
+ if (tmpErrorStr.toLowerCase().indexOf('duplicate') > -1 || tmpErrorStr.toLowerCase().indexOf('unique') > -1)
537
+ {
538
+ // Duplicate key (likely GUID conflict) -- fall back to update
539
+ this.log.warn(`${this.EntitySchema.TableName}: duplicate key on create for ID ${tmpRecord[this.DefaultIdentifier]}; falling back to update.`);
540
+ const tmpUpdateQuery = this.Meadow.query.addRecord(tmpRecordToCommit);
541
+ tmpUpdateQuery.setDisableAutoIdentity(true);
542
+ tmpUpdateQuery.setDisableAutoDateStamp(true);
543
+ tmpUpdateQuery.setDisableAutoUserStamp(true);
544
+ tmpUpdateQuery.setDisableDeleteTracking(true);
545
+ this.Meadow.doUpdate(tmpUpdateQuery,
546
+ (pUpdateError) =>
535
547
  {
536
- if (pCreateError)
548
+ if (pUpdateError)
537
549
  {
538
- let tmpErrorStr = (typeof(pCreateError) === 'string') ? pCreateError : JSON.stringify(pCreateError);
539
- if (tmpErrorStr.toLowerCase().indexOf('duplicate') > -1 || tmpErrorStr.toLowerCase().indexOf('unique') > -1)
540
- {
541
- // Duplicate key (likely GUID conflict) -- fall back to update
542
- this.log.warn(`${this.EntitySchema.TableName}: duplicate key on create for ID ${tmpRecord[this.DefaultIdentifier]}; falling back to update.`);
543
- const tmpUpdateQuery = this.Meadow.query.addRecord(tmpRecordToCommit);
544
- tmpUpdateQuery.setDisableAutoIdentity(true);
545
- tmpUpdateQuery.setDisableAutoDateStamp(true);
546
- tmpUpdateQuery.setDisableAutoUserStamp(true);
547
- tmpUpdateQuery.setDisableDeleteTracking(true);
548
- this.Meadow.doUpdate(tmpUpdateQuery,
549
- (pUpdateError) =>
550
- {
551
- if (pUpdateError)
552
- {
553
- tmpRecordsErrored++;
554
- this.log.error(`${this.EntitySchema.TableName}: fallback update also failed for ID ${tmpRecord[this.DefaultIdentifier]}: ${pUpdateError}`);
555
- return fEntitySyncComplete();
556
- }
557
- tmpRecordsCreated++;
558
- this.operation.incrementProgressTrackerStatus(`FullSync-${this.EntitySchema.TableName}`, 1);
559
- return fEntitySyncComplete();
560
- });
561
- return;
562
- }
563
550
  tmpRecordsErrored++;
564
- this.log.error(`${this.EntitySchema.TableName}: doCreate error for ID ${tmpRecord[this.DefaultIdentifier]}: ${pCreateError}`);
551
+ this.log.error(`${this.EntitySchema.TableName}: fallback update also failed for ID ${tmpRecord[this.DefaultIdentifier]}: ${pUpdateError}`);
565
552
  return fEntitySyncComplete();
566
553
  }
567
554
  tmpRecordsCreated++;
568
555
  this.operation.incrementProgressTrackerStatus(`FullSync-${this.EntitySchema.TableName}`, 1);
569
556
  return fEntitySyncComplete();
570
557
  });
558
+ return;
571
559
  }
572
- else
573
- {
574
- tmpRecordsSkipped++;
575
- return fEntitySyncComplete();
576
- }
577
- });
578
- },
579
- (pEntitySyncError) =>
580
- {
581
- this.operation.printProgressTrackerStatus(`FullSync-${this.EntitySchema.TableName}`);
582
- if (pEntitySyncError)
583
- {
584
- this.log.error(`Problem or early completion syncing entity ${this.EntitySchema.TableName}: ${pEntitySyncError}`, pEntitySyncError);
585
- }
586
- return fDownloadComplete();
587
- });
588
- }
589
- else
590
- {
591
- if (Array.isArray(pBody) && pBody.length == 0)
560
+ tmpRecordsErrored++;
561
+ this.log.error(`${this.EntitySchema.TableName}: doCreate error for ID ${tmpRecord[this.DefaultIdentifier]}: ${pCreateError}`);
562
+ return fEntitySyncComplete();
563
+ }
564
+ tmpRecordsCreated++;
565
+ this.operation.incrementProgressTrackerStatus(`FullSync-${this.EntitySchema.TableName}`, 1);
566
+ return fEntitySyncComplete();
567
+ });
568
+ }
569
+ else
592
570
  {
593
- return fDownloadComplete(new Error('Records depleted!'));
571
+ tmpRecordsSkipped++;
572
+ return fEntitySyncComplete();
594
573
  }
595
- return fDownloadComplete();
596
- }
597
- });
598
- },
599
- (pDownloadError) =>
574
+ });
575
+ },
576
+ (pEntitySyncError) =>
577
+ {
578
+ this.operation.printProgressTrackerStatus(`FullSync-${this.EntitySchema.TableName}`);
579
+ if (pEntitySyncError)
580
+ {
581
+ this.log.error(`Problem or early completion syncing entity ${this.EntitySchema.TableName}: ${pEntitySyncError}`, pEntitySyncError);
582
+ }
583
+ return fPageProcessComplete(pEntitySyncError);
584
+ });
585
+ };
586
+
587
+ const fSyncComplete = (pDownloadError) =>
588
+ {
589
+ this.fable.log.info(`${this.EntitySchema.TableName}: sync complete — created: ${tmpRecordsCreated}, skipped: ${tmpRecordsSkipped}, errors: ${tmpRecordsErrored}`);
590
+ if (pDownloadError)
591
+ {
592
+ this.fable.log.error(`Error returned URL Partial .. this may not be an error: ${pDownloadError}`);
593
+ }
594
+
595
+ // Store sync results on the entity so callers can inspect the breakdown
596
+ this.syncResults = (
597
+ {
598
+ Created: tmpRecordsCreated,
599
+ Skipped: tmpRecordsSkipped,
600
+ Errors: tmpRecordsErrored,
601
+ Deleted: 0,
602
+ ServerRecordCount: tmpSyncState.Server.RecordCount,
603
+ LocalRecordCount: tmpSyncState.Local.RecordCount,
604
+ ServerMaxID: tmpSyncState.Server.MaxIDEntity,
605
+ LocalMaxID: tmpSyncState.Local.MaxIDEntity,
606
+ EstimatedNew: tmpSyncState.EstimatedRecordCount
607
+ });
608
+
609
+ fStageComplete();
610
+ };
611
+
612
+ if (this.UseAdvancedIDPagination)
613
+ {
614
+ // Advanced ID pagination: use keyset pagination (WHERE ID > lastMaxID)
615
+ // instead of OFFSET to avoid progressive table scan slowdown on large datasets.
616
+ let tmpLastMaxID = tmpSyncState.Local.MaxIDEntity;
617
+ let tmpTotalFetched = 0;
618
+
619
+ const fFetchPage = () =>
600
620
  {
601
- this.fable.log.info(`${this.EntitySchema.TableName}: sync complete — created: ${tmpRecordsCreated}, skipped: ${tmpRecordsSkipped}, errors: ${tmpRecordsErrored}`);
602
- if (pDownloadError)
621
+ if (tmpTotalFetched >= tmpSyncState.RecordCap)
603
622
  {
604
- this.fable.log.error(`Error returned URL Partial .. this may not be an error: ${pDownloadError}`);
623
+ return fSyncComplete();
605
624
  }
606
625
 
607
- // Store sync results on the entity so callers can inspect the breakdown
608
- this.syncResults = (
626
+ tmpPageIndex++;
627
+ let tmpURL = `${this.EntitySchema.TableName}s/FilteredTo/FBV~${this.DefaultIdentifier}~GT~${tmpLastMaxID}~FSF~${this.DefaultIdentifier}~ASC~ASC/0/${this.PageSize}`;
628
+
629
+ this.fable.MeadowCloneRestClient.getJSON(tmpURL,
630
+ (pDownloadError, pResponse, pBody) =>
609
631
  {
610
- Created: tmpRecordsCreated,
611
- Skipped: tmpRecordsSkipped,
612
- Errors: tmpRecordsErrored,
613
- Deleted: 0,
614
- ServerRecordCount: tmpSyncState.Server.RecordCount,
615
- LocalRecordCount: tmpSyncState.Local.RecordCount,
616
- ServerMaxID: tmpSyncState.Server.MaxIDEntity,
617
- LocalMaxID: tmpSyncState.Local.MaxIDEntity,
618
- EstimatedNew: tmpSyncState.EstimatedRecordCount
632
+ if (pDownloadError)
633
+ {
634
+ this.fable.log.error(`${this.EntitySchema.TableName}: page ${tmpPageIndex} download error: ${pDownloadError}`);
635
+ return fSyncComplete(pDownloadError);
636
+ }
637
+ if (pBody && Array.isArray(pBody) && pBody.length > 0)
638
+ {
639
+ // Track the max ID from this page for the next page filter
640
+ for (let r = 0; r < pBody.length; r++)
641
+ {
642
+ if (pBody[r][this.DefaultIdentifier] > tmpLastMaxID)
643
+ {
644
+ tmpLastMaxID = pBody[r][this.DefaultIdentifier];
645
+ }
646
+ }
647
+ tmpTotalFetched += pBody.length;
648
+
649
+ fProcessPageRecords(pBody, (pProcessError) =>
650
+ {
651
+ if (pProcessError)
652
+ {
653
+ return fSyncComplete(pProcessError);
654
+ }
655
+ return setImmediate(fFetchPage);
656
+ });
657
+ }
658
+ else
659
+ {
660
+ // No more records
661
+ return fSyncComplete();
662
+ }
619
663
  });
664
+ };
665
+ fFetchPage();
666
+ }
667
+ else
668
+ {
669
+ // Standard offset-based pagination
670
+ this.fable.Utility.eachLimit(tmpSyncState.URLPartials, 1,
671
+ (pURLPartial, fDownloadComplete) =>
672
+ {
673
+ tmpPageIndex++;
620
674
 
621
- fStageComplete();
622
- });
675
+ this.fable.MeadowCloneRestClient.getJSON(pURLPartial,
676
+ (pDownloadError, pResponse, pBody) =>
677
+ {
678
+ if (pDownloadError)
679
+ {
680
+ this.fable.log.error(`${this.EntitySchema.TableName}: page ${tmpPageIndex} download error: ${pDownloadError}`);
681
+ return fDownloadComplete();
682
+ }
683
+ if (pBody && Array.isArray(pBody) && pBody.length > 0)
684
+ {
685
+ fProcessPageRecords(pBody, fDownloadComplete);
686
+ }
687
+ else
688
+ {
689
+ if (Array.isArray(pBody) && pBody.length == 0)
690
+ {
691
+ return fDownloadComplete(new Error('Records depleted!'));
692
+ }
693
+ return fDownloadComplete();
694
+ }
695
+ });
696
+ },
697
+ fSyncComplete);
698
+ }
623
699
  },
624
700
  ],
625
701
  (pError) =>
@@ -65,6 +65,18 @@ class MeadowSync extends libFableServiceProviderBase
65
65
  this.MaxRecordsPerEntity = parseInt(this.options.MaxRecordsPerEntity, 10) || 0;
66
66
  }
67
67
 
68
+ // When true, use ID-based keyset pagination instead of OFFSET pagination.
69
+ // This avoids table scans on large datasets by filtering WHERE ID > lastMaxID.
70
+ this.UseAdvancedIDPagination = false;
71
+ if (this.fable.ProgramConfiguration.hasOwnProperty('UseAdvancedIDPagination'))
72
+ {
73
+ this.UseAdvancedIDPagination = !!this.fable.ProgramConfiguration.UseAdvancedIDPagination;
74
+ }
75
+ else if (this.options.hasOwnProperty('UseAdvancedIDPagination'))
76
+ {
77
+ this.UseAdvancedIDPagination = !!this.options.UseAdvancedIDPagination;
78
+ }
79
+
68
80
  // Tolerance window in milliseconds for cross-database timestamp precision differences.
69
81
  // Passed through to Ongoing sync entities for bisection date comparison.
70
82
  this.DateTimePrecisionMS = 1000;
@@ -114,6 +126,7 @@ class MeadowSync extends libFableServiceProviderBase
114
126
  SyncDeletedRecords: this.SyncDeletedRecords,
115
127
  MaxRecordsPerEntity: this.MaxRecordsPerEntity,
116
128
  DateTimePrecisionMS: this.DateTimePrecisionMS,
129
+ UseAdvancedIDPagination: this.UseAdvancedIDPagination,
117
130
  };
118
131
 
119
132
  let tmpSyncEntity;