meadow-integration 1.0.12 → 1.0.14

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.12",
3
+ "version": "1.0.14",
4
4
  "description": "Meadow Data Integration",
5
5
  "bin": {
6
6
  "mdwint": "source/cli/Meadow-Integration-CLI-Run.js"
@@ -42,6 +42,19 @@ class MeadowSyncEntityOngoing extends libFableServiceProviderBase
42
42
  this.SyncDeletedRecords = this.options.SyncDeletedRecords || false;
43
43
  this.MaxRecordsPerEntity = this.options.MaxRecordsPerEntity || 0;
44
44
 
45
+ // Minimum range size for bisection -- when a range is this small or smaller,
46
+ // pull all records in the range from the server instead of subdividing further.
47
+ this.BisectMinRangeSize = this.options.BisectMinRangeSize || 1000;
48
+
49
+ // Tolerance window in milliseconds for cross-database timestamp precision differences.
50
+ // MySQL DATETIME stores whole seconds, MSSQL DATETIME rounds to ~3.33ms increments,
51
+ // PostgreSQL TIMESTAMP stores microseconds, and SQLite stores as TEXT. When comparing
52
+ // timestamps across systems, the maximum rounding error is 1000ms (MySQL second-level
53
+ // truncation). Default 1000ms covers all supported provider combinations.
54
+ this.DateTimePrecisionMS = (typeof(this.options.DateTimePrecisionMS) === 'number')
55
+ ? this.options.DateTimePrecisionMS
56
+ : 1000;
57
+
45
58
  this.Meadow = false;
46
59
 
47
60
  this.operation = new libMeadowOperation(this.fable);
@@ -144,6 +157,460 @@ class MeadowSyncEntityOngoing extends libFableServiceProviderBase
144
157
  return tmpRecordToCommit;
145
158
  }
146
159
 
160
+ // ---- REST / Local query helpers ----
161
+
162
+ // Normalize a date value to a UTC dayJS instance.
163
+ //
164
+ // Local dates stored in SQLite are in UTC but formatted without a timezone
165
+ // indicator (e.g. "2022-05-10 22:50:26.000"). When the driver or ORM wraps
166
+ // them in a JavaScript Date object, JS interprets them as local time, shifting
167
+ // the value by the machine's UTC offset. To recover the original naive UTC
168
+ // time, we format through local time (which undoes the offset) then re-parse
169
+ // as UTC. Server dates already carry a "Z" suffix and are parsed correctly.
170
+ _normalizeDateUTC(pDate)
171
+ {
172
+ if (typeof(pDate) === 'string')
173
+ {
174
+ // String dates — strip any trailing Z or timezone so dayJS.utc() treats as UTC
175
+ return this.fable.Dates.dayJS.utc(pDate);
176
+ }
177
+ // Date objects (from SQLite via ORM) — format as local time string to recover
178
+ // the naive stored value, then re-parse as UTC
179
+ let tmpNaiveStr = this.fable.Dates.dayJS(pDate).format('YYYY-MM-DD HH:mm:ss.SSS');
180
+ return this.fable.Dates.dayJS.utc(tmpNaiveStr);
181
+ }
182
+
183
+ // Format a date value for use in Meadow REST filter expressions (FBV).
184
+ _formatDateForFilter(pDate)
185
+ {
186
+ return this._normalizeDateUTC(pDate).format('YYYY-MM-DDTHH:mm:ss.SSS');
187
+ }
188
+
189
+ // Get a count from the remote server, optionally filtered.
190
+ _getServerCount(pFilter, fCallback)
191
+ {
192
+ const tmpURL = pFilter
193
+ ? `${this.EntitySchema.TableName}s/Count/FilteredTo/${pFilter}`
194
+ : `${this.EntitySchema.TableName}s/Count`;
195
+ this.fable.MeadowCloneRestClient.getJSON(tmpURL,
196
+ (pError, pResponse, pBody) =>
197
+ {
198
+ if (pError)
199
+ {
200
+ return fCallback(pError);
201
+ }
202
+ if (pBody && pBody.hasOwnProperty('Count'))
203
+ {
204
+ return fCallback(null, pBody.Count);
205
+ }
206
+ return fCallback(null, 0);
207
+ });
208
+ }
209
+
210
+ // Get a page of records from the remote server with a Meadow filter expression.
211
+ _getServerRecords(pFilter, pOffset, pPageSize, fCallback)
212
+ {
213
+ const tmpURL = `${this.EntitySchema.TableName}s/FilteredTo/${pFilter}/${pOffset}/${pPageSize}`;
214
+ this.fable.MeadowCloneRestClient.getJSON(tmpURL,
215
+ (pError, pResponse, pBody) =>
216
+ {
217
+ if (pError)
218
+ {
219
+ return fCallback(pError);
220
+ }
221
+ if (pBody && Array.isArray(pBody))
222
+ {
223
+ return fCallback(null, pBody);
224
+ }
225
+ return fCallback(null, []);
226
+ });
227
+ }
228
+
229
+ // Get a count from the local database with optional ID range filters.
230
+ _getLocalCount(pMinID, pMaxID, fCallback)
231
+ {
232
+ const tmpQuery = this.Meadow.query;
233
+ if (pMinID > 0)
234
+ {
235
+ tmpQuery.addFilter(this.DefaultIdentifier, pMinID, '>=');
236
+ }
237
+ if (pMaxID > 0)
238
+ {
239
+ tmpQuery.addFilter(this.DefaultIdentifier, pMaxID, '<=');
240
+ }
241
+ if (!this._hasDeletedColumn)
242
+ {
243
+ tmpQuery.setDisableDeleteTracking(true);
244
+ }
245
+ this.Meadow.doCount(tmpQuery,
246
+ (pError, pQuery, pCount) =>
247
+ {
248
+ if (pError)
249
+ {
250
+ return fCallback(pError);
251
+ }
252
+ return fCallback(null, pCount);
253
+ });
254
+ }
255
+
256
+ // Get the max UpdateDate from local records in an ID range.
257
+ _getLocalMaxUpdateDate(pMinID, pMaxID, fCallback)
258
+ {
259
+ const tmpQuery = this.Meadow.query;
260
+ if (pMinID > 0)
261
+ {
262
+ tmpQuery.addFilter(this.DefaultIdentifier, pMinID, '>=');
263
+ }
264
+ if (pMaxID > 0)
265
+ {
266
+ tmpQuery.addFilter(this.DefaultIdentifier, pMaxID, '<=');
267
+ }
268
+ tmpQuery.setSort({ Column: 'UpdateDate', Direction: 'Descending' });
269
+ tmpQuery.setCap(1);
270
+ if (!this._hasDeletedColumn)
271
+ {
272
+ tmpQuery.setDisableDeleteTracking(true);
273
+ }
274
+ this.Meadow.doRead(tmpQuery,
275
+ (pError, pQuery, pRecord) =>
276
+ {
277
+ if (pError)
278
+ {
279
+ return fCallback(pError);
280
+ }
281
+ if (!pRecord || !pRecord.UpdateDate)
282
+ {
283
+ return fCallback(null, false);
284
+ }
285
+ return fCallback(null, pRecord.UpdateDate);
286
+ });
287
+ }
288
+
289
+ // Get the min UpdateDate from local records in an ID range.
290
+ _getLocalMinUpdateDate(pMinID, pMaxID, fCallback)
291
+ {
292
+ const tmpQuery = this.Meadow.query;
293
+ if (pMinID > 0)
294
+ {
295
+ tmpQuery.addFilter(this.DefaultIdentifier, pMinID, '>=');
296
+ }
297
+ if (pMaxID > 0)
298
+ {
299
+ tmpQuery.addFilter(this.DefaultIdentifier, pMaxID, '<=');
300
+ }
301
+ tmpQuery.setSort({ Column: 'UpdateDate', Direction: 'Ascending' });
302
+ tmpQuery.setCap(1);
303
+ if (!this._hasDeletedColumn)
304
+ {
305
+ tmpQuery.setDisableDeleteTracking(true);
306
+ }
307
+ this.Meadow.doRead(tmpQuery,
308
+ (pError, pQuery, pRecord) =>
309
+ {
310
+ if (pError)
311
+ {
312
+ return fCallback(pError);
313
+ }
314
+ if (!pRecord || !pRecord.UpdateDate)
315
+ {
316
+ return fCallback(null, false);
317
+ }
318
+ return fCallback(null, pRecord.UpdateDate);
319
+ });
320
+ }
321
+
322
+ // Upsert a single record from the server into the local database.
323
+ _upsertRecord(pServerRecord, fCallback)
324
+ {
325
+ const tmpRecordToCommit = this.marshalRecord(pServerRecord);
326
+
327
+ const tmpQuery = this.Meadow.query;
328
+ tmpQuery.addFilter(this.DefaultIdentifier, pServerRecord[this.DefaultIdentifier]);
329
+ if (!this._hasDeletedColumn)
330
+ {
331
+ tmpQuery.setDisableDeleteTracking(true);
332
+ }
333
+
334
+ this.Meadow.doRead(tmpQuery,
335
+ (pReadError, pQuery, pLocalRecord) =>
336
+ {
337
+ if (pReadError)
338
+ {
339
+ this.fable.log.error(`Error reading local record ${this.EntitySchema.TableName} ID ${pServerRecord[this.DefaultIdentifier]}: ${pReadError}`);
340
+ return fCallback();
341
+ }
342
+
343
+ const tmpSyncQuery = this.Meadow.query.addRecord(tmpRecordToCommit);
344
+ tmpSyncQuery.setDisableAutoIdentity(true);
345
+ tmpSyncQuery.setDisableAutoDateStamp(true);
346
+ tmpSyncQuery.setDisableAutoUserStamp(true);
347
+ tmpSyncQuery.setDisableDeleteTracking(true);
348
+
349
+ if (!pLocalRecord)
350
+ {
351
+ // Record does not exist locally -- create
352
+ tmpSyncQuery.AllowIdentityInsert = true;
353
+ this.Meadow.doCreate(tmpSyncQuery,
354
+ (pCreateError) =>
355
+ {
356
+ if (pCreateError)
357
+ {
358
+ let tmpErrorStr = (typeof(pCreateError) === 'string') ? pCreateError : JSON.stringify(pCreateError);
359
+ if (tmpErrorStr.toLowerCase().indexOf('duplicate') > -1 || tmpErrorStr.toLowerCase().indexOf('unique') > -1)
360
+ {
361
+ // GUID conflict -- fall back to update
362
+ this.log.warn(`Duplicate key on create for ${this.EntitySchema.TableName} ID ${pServerRecord[this.DefaultIdentifier]}; falling back to update.`);
363
+ const tmpFallbackQuery = this.Meadow.query.addRecord(tmpRecordToCommit);
364
+ tmpFallbackQuery.setDisableAutoIdentity(true);
365
+ tmpFallbackQuery.setDisableAutoDateStamp(true);
366
+ tmpFallbackQuery.setDisableAutoUserStamp(true);
367
+ tmpFallbackQuery.setDisableDeleteTracking(true);
368
+ this.Meadow.doUpdate(tmpFallbackQuery,
369
+ (pUpdateError) =>
370
+ {
371
+ if (pUpdateError)
372
+ {
373
+ this.log.error(`Fallback update also failed for ${this.EntitySchema.TableName} ID ${pServerRecord[this.DefaultIdentifier]}: ${pUpdateError}`);
374
+ }
375
+ return fCallback();
376
+ });
377
+ return;
378
+ }
379
+ this.log.error(`Error creating record ${this.EntitySchema.TableName}: ${pCreateError}`, pCreateError);
380
+ return fCallback();
381
+ }
382
+ return fCallback();
383
+ });
384
+ }
385
+ else
386
+ {
387
+ // Record exists locally -- update
388
+ this.Meadow.doUpdate(tmpSyncQuery,
389
+ (pUpdateError) =>
390
+ {
391
+ if (pUpdateError)
392
+ {
393
+ this.log.error(`Error updating record ${this.EntitySchema.TableName}: ${pUpdateError}`, pUpdateError);
394
+ }
395
+ return fCallback();
396
+ });
397
+ }
398
+ });
399
+ }
400
+
401
+ // Pull all records from server matching a filter expression and upsert them locally.
402
+ // Fetches in pages of this.PageSize.
403
+ _pullServerRecords(pFilter, pEstimatedCount, fCallback)
404
+ {
405
+ if (pEstimatedCount < 1)
406
+ {
407
+ return fCallback(null, 0);
408
+ }
409
+
410
+ let tmpSyncedCount = 0;
411
+ let tmpOffset = 0;
412
+ let tmpDone = false;
413
+
414
+ let tmpRecordCap = (this.MaxRecordsPerEntity > 0)
415
+ ? Math.min(pEstimatedCount, this.MaxRecordsPerEntity)
416
+ : pEstimatedCount;
417
+
418
+ const fFetchPage = () =>
419
+ {
420
+ if (tmpDone || tmpOffset >= tmpRecordCap)
421
+ {
422
+ return fCallback(null, tmpSyncedCount);
423
+ }
424
+
425
+ this._getServerRecords(pFilter, tmpOffset, this.PageSize,
426
+ (pError, pRecords) =>
427
+ {
428
+ if (pError)
429
+ {
430
+ this.fable.log.error(`Error fetching ${this.EntitySchema.TableName} page at offset ${tmpOffset}: ${pError}`);
431
+ return fCallback(pError, tmpSyncedCount);
432
+ }
433
+ if (!pRecords || pRecords.length < 1)
434
+ {
435
+ tmpDone = true;
436
+ return fCallback(null, tmpSyncedCount);
437
+ }
438
+
439
+ this.fable.Utility.eachLimit(pRecords, 5,
440
+ (pRecord, fRecordDone) =>
441
+ {
442
+ this._upsertRecord(pRecord,
443
+ () =>
444
+ {
445
+ tmpSyncedCount++;
446
+ return fRecordDone();
447
+ });
448
+ },
449
+ (pUpsertError) =>
450
+ {
451
+ tmpOffset += this.PageSize;
452
+ if (pRecords.length < this.PageSize)
453
+ {
454
+ tmpDone = true;
455
+ return fCallback(null, tmpSyncedCount);
456
+ }
457
+ this.fable.log.info(`${this.EntitySchema.TableName}: pulled ${tmpSyncedCount} of ~${tmpRecordCap} records...`);
458
+ return fFetchPage();
459
+ });
460
+ });
461
+ };
462
+
463
+ fFetchPage();
464
+ }
465
+
466
+ // Increment the FullSync progress tracker by pCount records checked/synced.
467
+ _incrementProgress(pCount)
468
+ {
469
+ let tmpTracker = this.operation.progressTrackers[`FullSync-${this.EntitySchema.TableName}`];
470
+ if (tmpTracker && pCount > 0)
471
+ {
472
+ tmpTracker.CurrentCount = Math.min(tmpTracker.CurrentCount + pCount, tmpTracker.TotalCount);
473
+ }
474
+ }
475
+
476
+ // ---- Bisection logic ----
477
+
478
+ // Compare a local ID range against the server. If counts or date boundaries
479
+ // differ, subdivide until the range is small enough, then pull all records in
480
+ // the range from the server to bring local in sync.
481
+ _bisectRange(pMinID, pMaxID, pDepth, fCallback)
482
+ {
483
+ const tmpRangeSize = pMaxID - pMinID + 1;
484
+ const tmpIDCol = this.DefaultIdentifier;
485
+ const tmpRangeFilter = `FBV~${tmpIDCol}~GE~${pMinID}~FBV~${tmpIDCol}~LE~${pMaxID}`;
486
+
487
+ // Get local stats for this range
488
+ this._getLocalCount(pMinID, pMaxID,
489
+ (pLocalCountError, pLocalCount) =>
490
+ {
491
+ if (pLocalCountError)
492
+ {
493
+ this.fable.log.warn(`${this.EntitySchema.TableName}: bisect local count error for range ${pMinID}-${pMaxID}: ${pLocalCountError}`);
494
+ return fCallback();
495
+ }
496
+
497
+ // Get server count for this range
498
+ this._getServerCount(tmpRangeFilter,
499
+ (pServerCountError, pServerCount) =>
500
+ {
501
+ if (pServerCountError)
502
+ {
503
+ this.fable.log.warn(`${this.EntitySchema.TableName}: bisect server count error for range ${pMinID}-${pMaxID}: ${pServerCountError}`);
504
+ return fCallback();
505
+ }
506
+
507
+ // If counts match, check UpdateDate boundaries for this range
508
+ if (pLocalCount === pServerCount)
509
+ {
510
+ if (!this._hasUpdateDate)
511
+ {
512
+ // No UpdateDate column -- counts match, assume in sync
513
+ this._incrementProgress(pServerCount);
514
+ return fCallback();
515
+ }
516
+
517
+ // Compare max and min UpdateDate for this range
518
+ this._getLocalMaxUpdateDate(pMinID, pMaxID,
519
+ (pLocalMaxErr, pLocalMaxDate) =>
520
+ {
521
+ if (pLocalMaxErr || !pLocalMaxDate)
522
+ {
523
+ return fCallback();
524
+ }
525
+
526
+ // Get server max UpdateDate for this range (1 record, sorted desc)
527
+ const tmpMaxDateFilter = `${tmpRangeFilter}~FSF~UpdateDate~DESC~DESC`;
528
+ this._getServerRecords(tmpMaxDateFilter, 0, 1,
529
+ (pServerMaxErr, pServerMaxRecords) =>
530
+ {
531
+ if (pServerMaxErr || !pServerMaxRecords || pServerMaxRecords.length < 1)
532
+ {
533
+ return fCallback();
534
+ }
535
+
536
+ const tmpServerMaxDate = pServerMaxRecords[0].UpdateDate;
537
+ const tmpMaxDateDiff = Math.abs(this._normalizeDateUTC(pLocalMaxDate).diff(this._normalizeDateUTC(tmpServerMaxDate)));
538
+
539
+ if (tmpMaxDateDiff <= this.DateTimePrecisionMS)
540
+ {
541
+ // Max dates match and counts match -- this range is in sync
542
+ this._incrementProgress(pServerCount);
543
+ return fCallback();
544
+ }
545
+
546
+ // Dates differ even though counts match -- records have been modified.
547
+ // If range is small enough, pull all records; otherwise subdivide.
548
+ this.fable.log.info(`${this.EntitySchema.TableName}: date mismatch in range ${pMinID}-${pMaxID} (local max: ${pLocalMaxDate}, server max: ${tmpServerMaxDate})`);
549
+ if (tmpRangeSize <= this.BisectMinRangeSize)
550
+ {
551
+ return this._pullRangeFromServer(pMinID, pMaxID, fCallback);
552
+ }
553
+ return this._subdivideRange(pMinID, pMaxID, pDepth, fCallback);
554
+ });
555
+ });
556
+ return;
557
+ }
558
+
559
+ // Counts differ
560
+ this.fable.log.info(`${this.EntitySchema.TableName}: count mismatch in range ${pMinID}-${pMaxID} (local: ${pLocalCount}, server: ${pServerCount})`);
561
+
562
+ if (tmpRangeSize <= this.BisectMinRangeSize)
563
+ {
564
+ return this._pullRangeFromServer(pMinID, pMaxID, fCallback);
565
+ }
566
+
567
+ return this._subdivideRange(pMinID, pMaxID, pDepth, fCallback);
568
+ });
569
+ });
570
+ }
571
+
572
+ // Split an ID range in half and bisect each half.
573
+ _subdivideRange(pMinID, pMaxID, pDepth, fCallback)
574
+ {
575
+ const tmpMidID = Math.floor((pMinID + pMaxID) / 2);
576
+
577
+ this.fable.log.info(`${this.EntitySchema.TableName}: subdividing range ${pMinID}-${pMaxID} at ID ${tmpMidID} (depth ${pDepth})`);
578
+
579
+ // Bisect lower half, then upper half
580
+ this._bisectRange(pMinID, tmpMidID, pDepth + 1,
581
+ () =>
582
+ {
583
+ this._bisectRange(tmpMidID + 1, pMaxID, pDepth + 1, fCallback);
584
+ });
585
+ }
586
+
587
+ // Pull all records from the server in an ID range and upsert them locally.
588
+ _pullRangeFromServer(pMinID, pMaxID, fCallback)
589
+ {
590
+ const tmpIDCol = this.DefaultIdentifier;
591
+ const tmpFilter = `FBV~${tmpIDCol}~GE~${pMinID}~FBV~${tmpIDCol}~LE~${pMaxID}~FSF~${tmpIDCol}~ASC~ASC`;
592
+ const tmpEstimatedCount = pMaxID - pMinID + 1;
593
+
594
+ this.fable.log.info(`${this.EntitySchema.TableName}: pulling range ${pMinID}-${pMaxID} from server (~${tmpEstimatedCount} records)`);
595
+
596
+ this._pullServerRecords(tmpFilter, tmpEstimatedCount,
597
+ (pError, pSyncedCount) =>
598
+ {
599
+ if (pError)
600
+ {
601
+ this.fable.log.warn(`${this.EntitySchema.TableName}: error pulling range ${pMinID}-${pMaxID}: ${pError}`);
602
+ }
603
+ else
604
+ {
605
+ this.fable.log.info(`${this.EntitySchema.TableName}: synced ${pSyncedCount} records in range ${pMinID}-${pMaxID}`);
606
+ }
607
+ this._incrementProgress(pSyncedCount || 0);
608
+ return fCallback();
609
+ });
610
+ }
611
+
612
+ // ---- Deleted records sync ----
613
+
147
614
  syncDeletedRecords(fCallback)
148
615
  {
149
616
  const tmpDeletedColumn = this.EntitySchema.Columns.find((c) => c.Column == 'Deleted');
@@ -155,7 +622,6 @@ class MeadowSyncEntityOngoing extends libFableServiceProviderBase
155
622
 
156
623
  this.fable.log.info(`Checking for deleted records on server for ${this.EntitySchema.TableName}...`);
157
624
 
158
- // Get the count of deleted records from the server.
159
625
  // The explicit FBV~Deleted~EQ~1 filter overrides foxhound's automatic Deleted=0 filter.
160
626
  this.fable.MeadowCloneRestClient.getJSON(`${this.EntitySchema.TableName}s/Count/FilteredTo/FBV~Deleted~EQ~1`,
161
627
  (pError, pResponse, pBody) =>
@@ -277,149 +743,7 @@ class MeadowSyncEntityOngoing extends libFableServiceProviderBase
277
743
  });
278
744
  }
279
745
 
280
- addSyncAnticipateEntry(tmpSyncState, tmpAnticipate)
281
- {
282
- tmpAnticipate.anticipate(
283
- (fNext) =>
284
- {
285
- const tmpURLPartial = `${this.EntitySchema.TableName}s/FilteredTo/FBV~${this.DefaultIdentifier}~GT~${tmpSyncState.LastRequestedID}~FSF~${this.DefaultIdentifier}~ASC~ASC/0/${this.PageSize}`;
286
- this.fable.MeadowCloneRestClient.getJSON(tmpURLPartial,
287
- (pDownloadError, pResponse, pBody) =>
288
- {
289
- if (pDownloadError)
290
- {
291
- this.fable.log.error(`Error getting URL Partial [${tmpURLPartial}]: ${pDownloadError}`, { Error: pDownloadError });
292
- return fNext();
293
- }
294
- if (pBody && Array.isArray(pBody) && pBody.length > 0)
295
- {
296
- for (let i = 0; i < pBody.length; i++)
297
- {
298
- const tmpRecord = pBody[i];
299
-
300
- tmpAnticipate.anticipate(
301
- (fNextEntityRecordSync) =>
302
- {
303
- const tmpQuery = this.Meadow.query;
304
-
305
- if (tmpRecord[this.DefaultIdentifier] > tmpSyncState.LastRequestedID)
306
- {
307
- tmpSyncState.LastRequestedID = tmpRecord[this.DefaultIdentifier];
308
- }
309
-
310
- if ((typeof(tmpRecord[this.DefaultIdentifier]) !== 'undefined') && (tmpRecord[this.DefaultIdentifier] > 0))
311
- {
312
- tmpQuery.addFilter(this.DefaultIdentifier, tmpRecord[this.DefaultIdentifier]);
313
- }
314
-
315
- if (!tmpSyncState.HasDeletedColumn)
316
- {
317
- tmpQuery.setDisableDeleteTracking(true);
318
- }
319
-
320
- this.Meadow.doRead(tmpQuery,
321
- (pReadError, pQuery, pRecord) =>
322
- {
323
- if (pReadError)
324
- {
325
- this.fable.log.error(`Error reading record ${this.EntitySchema.TableName}: ${pReadError}`, { Error: pReadError, PassedRecord: tmpRecord });
326
- return fNextEntityRecordSync();
327
- }
328
-
329
- if (pRecord)
330
- {
331
- const tmpAgeDifference = this.fable.Dates.dayJS(tmpRecord.UpdateDate).diff(this.fable.Dates.dayJS(pRecord.UpdateDate));
332
-
333
- if (Math.abs(tmpAgeDifference) < 5)
334
- {
335
- return fNextEntityRecordSync();
336
- }
337
-
338
- this.fable.log.info(`Syncing ${this.EntitySchema.TableName} record ${tmpRecord[this.DefaultIdentifier]} with age difference of ${tmpAgeDifference} ms.`);
339
- }
340
-
341
- const tmpRecordToCommit = this.marshalRecord(tmpRecord);
342
-
343
- const tmpSyncQuery = this.Meadow.query.addRecord(tmpRecordToCommit);
344
-
345
- tmpSyncQuery.setDisableAutoIdentity(true);
346
- tmpSyncQuery.setDisableAutoDateStamp(true);
347
- tmpSyncQuery.setDisableAutoUserStamp(true);
348
- tmpSyncQuery.setDisableDeleteTracking(true);
349
-
350
- if (!pRecord)
351
- {
352
- // Record not found -- create
353
- tmpSyncQuery.AllowIdentityInsert = true;
354
-
355
- this.Meadow.doCreate(tmpSyncQuery,
356
- (pCreateError) =>
357
- {
358
- if (pCreateError)
359
- {
360
- let tmpErrorStr = (typeof(pCreateError) === 'string') ? pCreateError : JSON.stringify(pCreateError);
361
- if (tmpErrorStr.toLowerCase().indexOf('duplicate') > -1 || tmpErrorStr.toLowerCase().indexOf('unique') > -1)
362
- {
363
- // Duplicate key (likely GUID conflict) -- fall back to update
364
- this.log.warn(`Duplicate key on create for ${this.EntitySchema.TableName} ID ${tmpRecord[this.DefaultIdentifier]}; falling back to update.`);
365
- const tmpFallbackQuery = this.Meadow.query.addRecord(tmpRecordToCommit);
366
- tmpFallbackQuery.setDisableAutoIdentity(true);
367
- tmpFallbackQuery.setDisableAutoDateStamp(true);
368
- tmpFallbackQuery.setDisableAutoUserStamp(true);
369
- tmpFallbackQuery.setDisableDeleteTracking(true);
370
- this.Meadow.doUpdate(tmpFallbackQuery,
371
- (pUpdateError) =>
372
- {
373
- if (pUpdateError)
374
- {
375
- this.log.error(`Fallback update also failed for ${this.EntitySchema.TableName} ID ${tmpRecord[this.DefaultIdentifier]}: ${pUpdateError}`);
376
- return fNextEntityRecordSync();
377
- }
378
- this.operation.incrementProgressTrackerStatus(`UpdateSync-${this.EntitySchema.TableName}`, 1);
379
- return fNextEntityRecordSync();
380
- });
381
- return;
382
- }
383
- this.log.error(`Error creating record ${this.EntitySchema.TableName}: ${pCreateError}`, pCreateError);
384
- return fNextEntityRecordSync();
385
- }
386
- this.operation.incrementProgressTrackerStatus(`UpdateSync-${this.EntitySchema.TableName}`, 1);
387
- return fNextEntityRecordSync();
388
- });
389
- }
390
- else
391
- {
392
- // Record found -- update
393
- this.Meadow.doUpdate(tmpSyncQuery,
394
- (pUpdateError) =>
395
- {
396
- if (pUpdateError)
397
- {
398
- this.log.error(`Error updating record ${this.EntitySchema.TableName}: ${pUpdateError}`, pUpdateError);
399
- return fNextEntityRecordSync();
400
- }
401
- this.operation.incrementProgressTrackerStatus(`UpdateSync-${this.EntitySchema.TableName}`, 1);
402
- return fNextEntityRecordSync();
403
- });
404
- }
405
- });
406
- });
407
- }
408
- tmpSyncState.RequestsPerformed++;
409
- if (tmpSyncState.RequestsPerformed < tmpSyncState.EstimatedRequestCount)
410
- {
411
- this.fable.log.info(`Syncing ${this.EntitySchema.TableName} request ${tmpSyncState.RequestsPerformed} of ${tmpSyncState.EstimatedRequestCount}...`);
412
- this.addSyncAnticipateEntry(tmpSyncState, tmpAnticipate);
413
- }
414
- return fNext();
415
- }
416
- else
417
- {
418
- return fNext();
419
- }
420
- });
421
- });
422
- }
746
+ // ---- Main sync entry point ----
423
747
 
424
748
  sync(fCallback)
425
749
  {
@@ -454,53 +778,44 @@ class MeadowSyncEntityOngoing extends libFableServiceProviderBase
454
778
  {
455
779
  this.operation.createTimeStamp('EntityOngoingSync');
456
780
 
457
- let tmpAnticipate = this.fable.newAnticipate();
458
-
459
781
  const tmpSyncState = (
460
782
  {
461
- Local: { MaxIDEntity: -1, RecordCount: 0, HasUpdateDate: false, LatestUpdateDate: false },
462
- Server: { MaxIDEntity: -1, RecordCount: 0, HasUpdateDate: false, LatestUpdateDate: false },
783
+ Local: { MaxIDEntity: -1, RecordCount: 0 },
784
+ Server: { MaxIDEntity: -1, RecordCount: 0 },
463
785
  });
464
786
 
465
- this.fable.Utility.waterfall(
466
- [
467
- (fStageComplete) =>
468
- {
469
- if (!this.EntitySchema || !this.EntitySchema.MeadowSchema || !Array.isArray(this.EntitySchema.MeadowSchema.Schema))
470
- {
471
- return fStageComplete('MeadowSyncEntityOngoing requires a valid MeadowEntitySchema.MeadowSchema.Schema.');
472
- }
787
+ // Detect schema capabilities
788
+ this._hasUpdateDate = false;
789
+ this._hasDeletedColumn = false;
473
790
 
474
- for (let i = 0; i < this.EntitySchema.MeadowSchema.Schema.length; i++)
475
- {
476
- const tmpColumn = this.EntitySchema.MeadowSchema.Schema[i];
477
- if (tmpColumn.Column == 'UpdateDate')
478
- {
479
- tmpSyncState.Local.HasUpdateDate = true;
480
- tmpSyncState.Server.HasUpdateDate = true;
481
- }
482
- if (tmpColumn.Type == 'Deleted' || tmpColumn.Column == 'Deleted')
483
- {
484
- tmpSyncState.HasDeletedColumn = true;
485
- }
486
- }
791
+ if (this.EntitySchema && this.EntitySchema.MeadowSchema && Array.isArray(this.EntitySchema.MeadowSchema.Schema))
792
+ {
793
+ for (let i = 0; i < this.EntitySchema.MeadowSchema.Schema.length; i++)
794
+ {
795
+ const tmpColumn = this.EntitySchema.MeadowSchema.Schema[i];
796
+ if (tmpColumn.Column == 'UpdateDate')
797
+ {
798
+ this._hasUpdateDate = true;
799
+ }
800
+ if (tmpColumn.Type == 'Deleted' || tmpColumn.Column == 'Deleted')
801
+ {
802
+ this._hasDeletedColumn = true;
803
+ }
804
+ }
805
+ }
487
806
 
488
- if (tmpSyncState.Local.HasUpdateDate)
489
- {
490
- this.log.info(`Entity ${this.EntitySchema.TableName} has UpdateDate column.`);
491
- }
807
+ this.fable.log.info(`Syncing with ONGOING STRATEGY entity ${this.EntitySchema.TableName} (UpdateDate: ${this._hasUpdateDate}, Deleted: ${this._hasDeletedColumn})...`);
492
808
 
493
- this.log.info(`Syncing with UPDATE STRATEGY entity ${this.EntitySchema.TableName}...`);
494
- return fStageComplete();
495
- },
809
+ this.fable.Utility.waterfall(
810
+ [
811
+ // ---- Stage 1: Gather local stats ----
496
812
  (fStageComplete) =>
497
813
  {
498
- // Get the Max ID from local database
814
+ // Local max ID
499
815
  const tmpQuery = this.Meadow.query;
500
816
  tmpQuery.setSort({ Column: this.DefaultIdentifier, Direction: 'Descending' });
501
817
  tmpQuery.setCap(1);
502
- // Disable delete tracking if the table has no Deleted column
503
- if (!tmpSyncState.HasDeletedColumn)
818
+ if (!this._hasDeletedColumn)
504
819
  {
505
820
  tmpQuery.setDisableDeleteTracking(true);
506
821
  }
@@ -509,31 +824,32 @@ class MeadowSyncEntityOngoing extends libFableServiceProviderBase
509
824
  {
510
825
  if (pReadError)
511
826
  {
512
- this.fable.log.error(`Error reading local max entity ID ${this.EntitySchema.TableName}: ${pReadError}`, { Error: pReadError });
827
+ this.fable.log.error(`Error reading local max entity ID ${this.EntitySchema.TableName}: ${pReadError}`);
513
828
  return fStageComplete(`Error reading local max entity ID ${this.EntitySchema.TableName}: ${pReadError}`);
514
829
  }
515
- if (!pRecord)
830
+ if (pRecord)
516
831
  {
517
- this.fable.log.warn(`No records found in local ${this.EntitySchema.TableName}.`);
518
- return fStageComplete();
832
+ tmpSyncState.Local.MaxIDEntity = pRecord[this.DefaultIdentifier];
833
+ this.fable.log.info(`Found local max entity ID ${this.EntitySchema.TableName}: ${tmpSyncState.Local.MaxIDEntity}`);
834
+ }
835
+ else
836
+ {
837
+ this.fable.log.info(`No local records for ${this.EntitySchema.TableName}.`);
519
838
  }
520
- this.fable.log.info(`Found local max entity ID ${this.EntitySchema.TableName}: ${pRecord[this.DefaultIdentifier]}`);
521
- tmpSyncState.Local.MaxIDEntity = pRecord[this.DefaultIdentifier];
522
839
  return fStageComplete();
523
840
  });
524
841
  },
525
842
  (fStageComplete) =>
526
843
  {
527
- // Get the Max UpdateDate from local database — skip if table has no UpdateDate column
528
- if (!tmpSyncState.Local.HasUpdateDate)
844
+ // Local max UpdateDate
845
+ if (!this._hasUpdateDate)
529
846
  {
530
- this.fable.log.info(`No UpdateDate column for ${this.EntitySchema.TableName}; skipping UpdateDate check.`);
531
847
  return fStageComplete();
532
848
  }
533
849
  const tmpQuery = this.Meadow.query;
534
850
  tmpQuery.setSort({ Column: 'UpdateDate', Direction: 'Descending' });
535
851
  tmpQuery.setCap(1);
536
- if (!tmpSyncState.HasDeletedColumn)
852
+ if (!this._hasDeletedColumn)
537
853
  {
538
854
  tmpQuery.setDisableDeleteTracking(true);
539
855
  }
@@ -542,24 +858,22 @@ class MeadowSyncEntityOngoing extends libFableServiceProviderBase
542
858
  {
543
859
  if (pReadError)
544
860
  {
545
- this.fable.log.error(`Error reading local max UpdateDate ${this.EntitySchema.TableName}: ${pReadError}`, { Error: pReadError });
861
+ this.fable.log.error(`Error reading local max UpdateDate ${this.EntitySchema.TableName}: ${pReadError}`);
546
862
  return fStageComplete(`Error reading local max UpdateDate ${this.EntitySchema.TableName}: ${pReadError}`);
547
863
  }
548
- if (!pRecord)
864
+ if (pRecord && pRecord.UpdateDate)
549
865
  {
550
- this.fable.log.warn(`No records found in local checking UpdateDate ${this.EntitySchema.TableName}.`);
551
- return fStageComplete();
866
+ tmpSyncState.Local.MaxUpdateDate = pRecord.UpdateDate;
867
+ this.fable.log.info(`Found local max UpdateDate ${this.EntitySchema.TableName}: ${tmpSyncState.Local.MaxUpdateDate}`);
552
868
  }
553
- this.fable.log.info(`Found local max UpdateDate ${this.EntitySchema.TableName}: ${pRecord.UpdateDate}`);
554
- tmpSyncState.Local.MaxUpdateDate = pRecord.UpdateDate;
555
869
  return fStageComplete();
556
870
  });
557
871
  },
558
872
  (fStageComplete) =>
559
873
  {
560
- // Get the count from local database
874
+ // Local count
561
875
  const tmpQuery = this.Meadow.query;
562
- if (!tmpSyncState.HasDeletedColumn)
876
+ if (!this._hasDeletedColumn)
563
877
  {
564
878
  tmpQuery.setDisableDeleteTracking(true);
565
879
  }
@@ -568,16 +882,19 @@ class MeadowSyncEntityOngoing extends libFableServiceProviderBase
568
882
  {
569
883
  if (pCountError)
570
884
  {
571
- this.fable.log.error(`Error getting local count of ${this.EntitySchema.TableName}: ${pCountError}`, { Error: pCountError });
885
+ this.fable.log.error(`Error getting local count of ${this.EntitySchema.TableName}: ${pCountError}`);
572
886
  return fStageComplete(`Error getting local count of ${this.EntitySchema.TableName}: ${pCountError}`);
573
887
  }
574
888
  tmpSyncState.Local.RecordCount = pCount;
889
+ this.fable.log.info(`Local count ${this.EntitySchema.TableName}: ${tmpSyncState.Local.RecordCount}`);
575
890
  return fStageComplete();
576
891
  });
577
892
  },
893
+
894
+ // ---- Stage 2: Gather server stats ----
578
895
  (fStageComplete) =>
579
896
  {
580
- // Get the Max ID from server
897
+ // Server max ID
581
898
  this.fable.MeadowCloneRestClient.getJSON(`${this.EntitySchema.TableName}/Max/${this.DefaultIdentifier}`,
582
899
  (pError, pResponse, pBody) =>
583
900
  {
@@ -588,8 +905,8 @@ class MeadowSyncEntityOngoing extends libFableServiceProviderBase
588
905
  }
589
906
  if (pBody && pBody.hasOwnProperty(this.DefaultIdentifier))
590
907
  {
591
- this.fable.log.info(`Found server max entity ID ${this.EntitySchema.TableName}: ${pBody[this.DefaultIdentifier]}`);
592
908
  tmpSyncState.Server.MaxIDEntity = pBody[this.DefaultIdentifier];
909
+ this.fable.log.info(`Found server max entity ID ${this.EntitySchema.TableName}: ${tmpSyncState.Server.MaxIDEntity}`);
593
910
  }
594
911
  else
595
912
  {
@@ -600,85 +917,210 @@ class MeadowSyncEntityOngoing extends libFableServiceProviderBase
600
917
  },
601
918
  (fStageComplete) =>
602
919
  {
603
- // Get the Max UpdateDate from server
604
- this.fable.MeadowCloneRestClient.getJSON(`${this.EntitySchema.TableName}/Max/UpdateDate`,
605
- (pError, pResponse, pBody) =>
920
+ // Server count
921
+ this._getServerCount(null,
922
+ (pError, pCount) =>
606
923
  {
607
924
  if (pError)
608
925
  {
609
- this.fable.log.warn(`Could not get server max UpdateDate for ${this.EntitySchema.TableName} (${pError}); will sync by ID only.`);
926
+ this.fable.log.warn(`Could not get server count for ${this.EntitySchema.TableName} (${pError}); estimating from max ID.`);
927
+ tmpSyncState.Server.RecordCount = tmpSyncState.Server.MaxIDEntity > 0 ? tmpSyncState.Server.MaxIDEntity : 0;
610
928
  return fStageComplete();
611
929
  }
612
- if (pBody && pBody.hasOwnProperty(this.DefaultIdentifier))
613
- {
614
- this.fable.log.info(`Found server max UpdateDate ${this.EntitySchema.TableName}: ${pBody['UpdateDate']}`);
615
- tmpSyncState.Server.MaxUpdateDate = pBody.UpdateDate;
616
- }
617
- else
618
- {
619
- this.fable.log.warn(`No records found in server for max UpdateDate of ${this.EntitySchema.TableName}.`);
620
- }
930
+ tmpSyncState.Server.RecordCount = pCount;
931
+ this.fable.log.info(`Server count ${this.EntitySchema.TableName}: ${tmpSyncState.Server.RecordCount}`);
621
932
  return fStageComplete();
622
933
  });
623
934
  },
935
+
936
+ // Create a progress tracker so callers (e.g. data-cloner UI) can see Total/Synced
624
937
  (fStageComplete) =>
625
938
  {
626
- // Get the count from server
627
- this.fable.MeadowCloneRestClient.getJSON(`${this.EntitySchema.TableName}s/Count`,
628
- (pError, pResponse, pBody) =>
939
+ this.operation.createProgressTracker(tmpSyncState.Server.RecordCount, `FullSync-${this.EntitySchema.TableName}`);
940
+ return fStageComplete();
941
+ },
942
+
943
+ // ---- Stage 3: UpdateDate-based fast sync ----
944
+ // If we have UpdateDate, compare server record count up to our local
945
+ // max UpdateDate. If it matches local count, existing records are in
946
+ // sync and we only need to pull records newer than that date.
947
+ (fStageComplete) =>
948
+ {
949
+ if (!this._hasUpdateDate || !tmpSyncState.Local.MaxUpdateDate)
950
+ {
951
+ this.fable.log.info(`${this.EntitySchema.TableName}: no UpdateDate available; skipping UpdateDate fast-sync.`);
952
+ tmpSyncState.UpdateDateSyncDone = false;
953
+ return fStageComplete();
954
+ }
955
+
956
+ const tmpDateStr = this._formatDateForFilter(tmpSyncState.Local.MaxUpdateDate);
957
+ const tmpIDCol = this.DefaultIdentifier;
958
+ const tmpBeforeFilter = `FBV~UpdateDate~LE~${tmpDateStr}`;
959
+
960
+ this.fable.log.info(`${this.EntitySchema.TableName}: checking server count with UpdateDate <= ${tmpDateStr}...`);
961
+
962
+ this._getServerCount(tmpBeforeFilter,
963
+ (pError, pServerCountBefore) =>
629
964
  {
630
965
  if (pError)
631
966
  {
632
- this.fable.log.warn(`Could not get server count for ${this.EntitySchema.TableName} (${pError}); estimating from max ID.`);
633
- tmpSyncState.Server.RecordCount = tmpSyncState.Server.MaxIDEntity > 0 ? tmpSyncState.Server.MaxIDEntity : 0;
967
+ this.fable.log.warn(`${this.EntitySchema.TableName}: could not get server count before UpdateDate (${pError}); falling back to bisection.`);
968
+ tmpSyncState.UpdateDateSyncDone = false;
634
969
  return fStageComplete();
635
970
  }
636
- if (pBody && pBody.hasOwnProperty('Count'))
971
+
972
+ this.fable.log.info(`${this.EntitySchema.TableName}: server has ${pServerCountBefore} records with UpdateDate <= ${tmpDateStr} (local has ${tmpSyncState.Local.RecordCount})`);
973
+
974
+ if (pServerCountBefore === tmpSyncState.Local.RecordCount)
637
975
  {
638
- this.fable.log.info(`Found server count for ${this.EntitySchema.TableName}: ${pBody.Count}`);
639
- tmpSyncState.Server.RecordCount = pBody.Count;
976
+ // Record counts match up to our max UpdateDate -- existing records are in sync.
977
+ this.fable.log.info(`${this.EntitySchema.TableName}: counts match up to local max UpdateDate; existing records appear in sync.`);
978
+ tmpSyncState.ExistingRecordsInSync = true;
979
+ this._incrementProgress(pServerCountBefore);
640
980
  }
641
981
  else
642
982
  {
643
- this.fable.log.warn(`No records found in server based on count for ${this.EntitySchema.TableName}.`);
983
+ this.fable.log.info(`${this.EntitySchema.TableName}: count mismatch before max UpdateDate (local: ${tmpSyncState.Local.RecordCount}, server: ${pServerCountBefore}); will bisect existing records.`);
984
+ tmpSyncState.ExistingRecordsInSync = false;
644
985
  }
645
- return fStageComplete();
986
+
987
+ // Now pull records with UpdateDate > local max UpdateDate (new + modified on server)
988
+ const tmpAfterFilter = `FBV~UpdateDate~GT~${tmpDateStr}~FSF~${tmpIDCol}~ASC~ASC`;
989
+
990
+ this._getServerCount(`FBV~UpdateDate~GT~${tmpDateStr}`,
991
+ (pAfterError, pServerCountAfter) =>
992
+ {
993
+ if (pAfterError)
994
+ {
995
+ this.fable.log.warn(`${this.EntitySchema.TableName}: could not get server count after UpdateDate (${pAfterError}).`);
996
+ tmpSyncState.UpdateDateSyncDone = false;
997
+ return fStageComplete();
998
+ }
999
+
1000
+ this.fable.log.info(`${this.EntitySchema.TableName}: ${pServerCountAfter} records on server with UpdateDate > ${tmpDateStr}; pulling...`);
1001
+
1002
+ if (pServerCountAfter < 1)
1003
+ {
1004
+ tmpSyncState.UpdateDateSyncDone = true;
1005
+ // No new records -- nothing additional to count
1006
+ return fStageComplete();
1007
+ }
1008
+
1009
+ this._pullServerRecords(tmpAfterFilter, pServerCountAfter,
1010
+ (pPullError, pSyncedCount) =>
1011
+ {
1012
+ if (pPullError)
1013
+ {
1014
+ this.fable.log.warn(`${this.EntitySchema.TableName}: error pulling new records: ${pPullError}`);
1015
+ }
1016
+ else
1017
+ {
1018
+ this.fable.log.info(`${this.EntitySchema.TableName}: pulled ${pSyncedCount} new/modified records via UpdateDate.`);
1019
+ }
1020
+ this._incrementProgress(pSyncedCount || 0);
1021
+ tmpSyncState.UpdateDateSyncDone = true;
1022
+ return fStageComplete();
1023
+ });
1024
+ });
646
1025
  });
647
1026
  },
1027
+
1028
+ // ---- Stage 4: Bisect existing records if counts did not match ----
648
1029
  (fStageComplete) =>
649
1030
  {
650
- let tmpTotalRecords = (this.MaxRecordsPerEntity > 0)
651
- ? Math.min(tmpSyncState.Server.RecordCount, this.MaxRecordsPerEntity)
652
- : tmpSyncState.Server.RecordCount;
653
- tmpSyncState.EstimatedRequestCount = Math.ceil(tmpTotalRecords / this.PageSize);
654
- tmpSyncState.RequestsPerformed = 0;
655
- tmpSyncState.LastRequestedID = 0;
1031
+ // If UpdateDate sync found existing records in sync, or if we have
1032
+ // no local data yet, skip bisection.
1033
+ if (tmpSyncState.ExistingRecordsInSync)
1034
+ {
1035
+ this.fable.log.info(`${this.EntitySchema.TableName}: existing records in sync; skipping bisection.`);
1036
+ return fStageComplete();
1037
+ }
656
1038
 
657
- this.operation.createProgressTracker(tmpSyncState.EstimatedRequestCount, `UpdateSync-${this.EntitySchema.TableName}`);
658
- this.operation.printProgressTrackerStatus(`UpdateSync-${this.EntitySchema.TableName}`);
1039
+ // If we have no local records, there is nothing to bisect
1040
+ if (tmpSyncState.Local.MaxIDEntity < 1)
1041
+ {
1042
+ this.fable.log.info(`${this.EntitySchema.TableName}: no local records; skipping bisection.`);
1043
+ return fStageComplete();
1044
+ }
659
1045
 
660
- return fStageComplete();
1046
+ // If the UpdateDate fast-sync already ran and pulled new records,
1047
+ // refresh local count to see if we are now in sync
1048
+ if (tmpSyncState.UpdateDateSyncDone)
1049
+ {
1050
+ return this._getLocalCount(0, 0,
1051
+ (pError, pNewLocalCount) =>
1052
+ {
1053
+ if (pError || pNewLocalCount === tmpSyncState.Server.RecordCount)
1054
+ {
1055
+ this.fable.log.info(`${this.EntitySchema.TableName}: counts now match after UpdateDate pull (${pNewLocalCount} local, ${tmpSyncState.Server.RecordCount} server); skipping bisection.`);
1056
+ return fStageComplete();
1057
+ }
1058
+
1059
+ this.fable.log.info(`${this.EntitySchema.TableName}: counts still differ after UpdateDate pull (${pNewLocalCount} local, ${tmpSyncState.Server.RecordCount} server); bisecting existing records...`);
1060
+ return this._bisectRange(1, tmpSyncState.Local.MaxIDEntity, 0, fStageComplete);
1061
+ });
1062
+ }
1063
+
1064
+ // No UpdateDate available -- bisect the full ID range
1065
+ this.fable.log.info(`${this.EntitySchema.TableName}: bisecting full ID range 1-${tmpSyncState.Local.MaxIDEntity}...`);
1066
+ return this._bisectRange(1, tmpSyncState.Local.MaxIDEntity, 0, fStageComplete);
661
1067
  },
1068
+
1069
+ // ---- Stage 5: Pull any remaining new records by ID ----
1070
+ // If no UpdateDate sync ran (table lacks UpdateDate), pull records
1071
+ // with ID > local max ID.
662
1072
  (fStageComplete) =>
663
1073
  {
664
- if (tmpSyncState.EstimatedRequestCount < 1)
1074
+ if (tmpSyncState.UpdateDateSyncDone)
665
1075
  {
666
- this.fable.log.info(`No records to update sync for ${this.EntitySchema.TableName}.`);
1076
+ // UpdateDate sync already handled new records
667
1077
  return fStageComplete();
668
1078
  }
669
1079
 
670
- this.addSyncAnticipateEntry(tmpSyncState, tmpAnticipate);
1080
+ if (tmpSyncState.Server.MaxIDEntity <= tmpSyncState.Local.MaxIDEntity)
1081
+ {
1082
+ this.fable.log.info(`${this.EntitySchema.TableName}: no new records by ID (server max ${tmpSyncState.Server.MaxIDEntity} <= local max ${tmpSyncState.Local.MaxIDEntity}).`);
1083
+ return fStageComplete();
1084
+ }
1085
+
1086
+ const tmpIDCol = this.DefaultIdentifier;
1087
+ const tmpFilter = `FBV~${tmpIDCol}~GT~${tmpSyncState.Local.MaxIDEntity}~FSF~${tmpIDCol}~ASC~ASC`;
1088
+ const tmpEstimated = tmpSyncState.Server.MaxIDEntity - tmpSyncState.Local.MaxIDEntity;
1089
+
1090
+ this.fable.log.info(`${this.EntitySchema.TableName}: pulling new records with ID > ${tmpSyncState.Local.MaxIDEntity} (~${tmpEstimated} estimated)...`);
671
1091
 
672
- tmpAnticipate.wait(fStageComplete);
1092
+ this._pullServerRecords(tmpFilter, tmpEstimated,
1093
+ (pError, pSyncedCount) =>
1094
+ {
1095
+ if (pError)
1096
+ {
1097
+ this.fable.log.warn(`${this.EntitySchema.TableName}: error pulling new records by ID: ${pError}`);
1098
+ }
1099
+ else
1100
+ {
1101
+ this.fable.log.info(`${this.EntitySchema.TableName}: pulled ${pSyncedCount} new records by ID.`);
1102
+ }
1103
+ this._incrementProgress(pSyncedCount || 0);
1104
+ return fStageComplete();
1105
+ });
673
1106
  },
674
1107
  ],
675
1108
  (pError) =>
676
1109
  {
677
1110
  if (pError)
678
1111
  {
679
- this.fable.log.error(`Error performing Update sync ${this.EntitySchema.TableName}: ${pError}`, { Error: pError });
1112
+ this.fable.log.error(`Error performing ongoing sync ${this.EntitySchema.TableName}: ${pError}`, { Error: pError });
680
1113
  }
681
1114
 
1115
+ // Mark progress tracker as complete so the UI shows the correct totals
1116
+ let tmpTracker = this.operation.progressTrackers[`FullSync-${this.EntitySchema.TableName}`];
1117
+ if (tmpTracker)
1118
+ {
1119
+ tmpTracker.CurrentCount = tmpTracker.TotalCount;
1120
+ }
1121
+
1122
+ this.fable.log.info(`${this.EntitySchema.TableName}: ongoing sync complete.`);
1123
+
682
1124
  if (this.SyncDeletedRecords)
683
1125
  {
684
1126
  return this.syncDeletedRecords(() => { return fCallback(); });
@@ -65,6 +65,18 @@ class MeadowSync extends libFableServiceProviderBase
65
65
  this.MaxRecordsPerEntity = parseInt(this.options.MaxRecordsPerEntity, 10) || 0;
66
66
  }
67
67
 
68
+ // Tolerance window in milliseconds for cross-database timestamp precision differences.
69
+ // Passed through to Ongoing sync entities for bisection date comparison.
70
+ this.DateTimePrecisionMS = 1000;
71
+ if (this.fable.ProgramConfiguration.hasOwnProperty('DateTimePrecisionMS'))
72
+ {
73
+ this.DateTimePrecisionMS = parseInt(this.fable.ProgramConfiguration.DateTimePrecisionMS, 10) || 1000;
74
+ }
75
+ else if (this.options.hasOwnProperty('DateTimePrecisionMS'))
76
+ {
77
+ this.DateTimePrecisionMS = parseInt(this.options.DateTimePrecisionMS, 10) || 1000;
78
+ }
79
+
68
80
  this.MeadowSchema = false;
69
81
  this.MeadowSchemaTableList = false;
70
82
 
@@ -101,6 +113,7 @@ class MeadowSync extends libFableServiceProviderBase
101
113
  PageSize: this.options.PageSize || 100,
102
114
  SyncDeletedRecords: this.SyncDeletedRecords,
103
115
  MaxRecordsPerEntity: this.MaxRecordsPerEntity,
116
+ DateTimePrecisionMS: this.DateTimePrecisionMS,
104
117
  };
105
118
 
106
119
  let tmpSyncEntity;