meadow-integration 1.0.11 → 1.0.13

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.11",
3
+ "version": "1.0.13",
4
4
  "description": "Meadow Data Integration",
5
5
  "bin": {
6
6
  "mdwint": "source/cli/Meadow-Integration-CLI-Run.js"
@@ -74,33 +74,7 @@ class MeadowSyncEntityInitial extends libFableServiceProviderBase
74
74
 
75
75
  return tmpProvider.createTable(this.EntitySchema, (pCreateError) =>
76
76
  {
77
- let fValidateAndCallback = (pPriorError) =>
78
- {
79
- // Validate local table schema with a lightweight read
80
- const tmpValidationQuery = this.Meadow.query;
81
- tmpValidationQuery.setCap(1);
82
- tmpValidationQuery.setDisableDeleteTracking(true);
83
- this.Meadow.doRead(tmpValidationQuery,
84
- (pReadError) =>
85
- {
86
- if (pReadError)
87
- {
88
- let tmpErrorStr = (typeof(pReadError) === 'string') ? pReadError : JSON.stringify(pReadError);
89
- // Only skip sync for schema-specific errors (invalid column/object name)
90
- // Generic provider errors (e.g. prepared statement failures) should not block sync
91
- if (tmpErrorStr.indexOf('Invalid column') > -1 || tmpErrorStr.indexOf('Invalid object') > -1 || tmpErrorStr.indexOf('no such column') > -1 || tmpErrorStr.indexOf('no such table') > -1)
92
- {
93
- this.log.warn(`${this.EntitySchema.TableName}: local table schema validation failed (${pReadError}); this entity will be skipped during sync.`);
94
- this.skipSync = true;
95
- }
96
- else
97
- {
98
- this.log.warn(`${this.EntitySchema.TableName}: validation read returned error (${pReadError}); sync will proceed.`);
99
- }
100
- }
101
- return fCallback(pPriorError);
102
- });
103
- };
77
+
104
78
  if (pCreateError)
105
79
  {
106
80
  this.log.warn(`${this.EntitySchema.TableName}: createTable returned error: ${pCreateError}`);
@@ -112,13 +86,13 @@ class MeadowSyncEntityInitial extends libFableServiceProviderBase
112
86
  if (!tmpGUIDColumn && !tmpDeletedColumn)
113
87
  {
114
88
  this.log.info(`No GUID or Deleted columns for ${this.EntitySchema.TableName}; skipping index creation`);
115
- return fValidateAndCallback(pCreateError);
89
+ return fCallback(pCreateError);
116
90
  }
117
91
 
118
92
  if (!this.fable.MeadowConnectionManager || !this.fable.MeadowConnectionManager.ConnectionPool)
119
93
  {
120
94
  this.log.info(`No connection manager available; skipping index creation for ${this.EntitySchema.TableName}`);
121
- return fValidateAndCallback(pCreateError);
95
+ return fCallback(pCreateError);
122
96
  }
123
97
 
124
98
  let tmpAnticipate = this.fable.newAnticipate();
@@ -336,6 +310,29 @@ class MeadowSyncEntityInitial extends libFableServiceProviderBase
336
310
  return fCallback();
337
311
  }
338
312
 
313
+ // Validate local table schema with a lightweight read before syncing
314
+ const tmpValidationQuery = this.Meadow.query;
315
+ tmpValidationQuery.setSort({ Column: this.DefaultIdentifier, Direction: 'Descending' });
316
+ tmpValidationQuery.setCap(1);
317
+ tmpValidationQuery.setDisableDeleteTracking(true);
318
+ this.Meadow.doRead(tmpValidationQuery,
319
+ (pReadError) =>
320
+ {
321
+ if (pReadError)
322
+ {
323
+ let tmpErrorStr = (typeof(pReadError) === 'string') ? pReadError : JSON.stringify(pReadError);
324
+ if (tmpErrorStr.indexOf('Invalid column') > -1 || tmpErrorStr.indexOf('Invalid object') > -1 || tmpErrorStr.indexOf('no such column') > -1 || tmpErrorStr.indexOf('no such table') > -1)
325
+ {
326
+ this.log.warn(`${this.EntitySchema.TableName}: local table schema mismatch (${pReadError}); skipping sync.`);
327
+ return fCallback();
328
+ }
329
+ }
330
+ return this._syncInternal(fCallback);
331
+ });
332
+ }
333
+
334
+ _syncInternal(fCallback)
335
+ {
339
336
  this.operation.createTimeStamp('EntityInitialSync');
340
337
 
341
338
  this.log.info(`Syncing ${this.EntitySchema.TableName} (PageSize: ${this.PageSize}, SyncDeletedRecords: ${this.SyncDeletedRecords})`);
@@ -42,6 +42,10 @@ 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
+
45
49
  this.Meadow = false;
46
50
 
47
51
  this.operation = new libMeadowOperation(this.fable);
@@ -62,46 +66,20 @@ class MeadowSyncEntityOngoing extends libFableServiceProviderBase
62
66
  {
63
67
  return this.Meadow.provider.getProvider().createTable(this.EntitySchema, (pCreateError) =>
64
68
  {
65
- let fValidateAndCallback = (pPriorError) =>
66
- {
67
- // Validate local table schema with a lightweight read
68
- const tmpValidationQuery = this.Meadow.query;
69
- tmpValidationQuery.setCap(1);
70
- tmpValidationQuery.setDisableDeleteTracking(true);
71
- this.Meadow.doRead(tmpValidationQuery,
72
- (pReadError) =>
73
- {
74
- if (pReadError)
75
- {
76
- let tmpErrorStr = (typeof(pReadError) === 'string') ? pReadError : JSON.stringify(pReadError);
77
- // Only skip sync for schema-specific errors (invalid column/object name)
78
- // Generic provider errors (e.g. prepared statement failures) should not block sync
79
- if (tmpErrorStr.indexOf('Invalid column') > -1 || tmpErrorStr.indexOf('Invalid object') > -1 || tmpErrorStr.indexOf('no such column') > -1 || tmpErrorStr.indexOf('no such table') > -1)
80
- {
81
- this.log.warn(`${this.EntitySchema.TableName}: local table schema validation failed (${pReadError}); this entity will be skipped during sync.`);
82
- this.skipSync = true;
83
- }
84
- else
85
- {
86
- this.log.warn(`${this.EntitySchema.TableName}: validation read returned error (${pReadError}); sync will proceed.`);
87
- }
88
- }
89
- return fCallback(pPriorError);
90
- });
91
- };
69
+
92
70
  const tmpGUIDColumn = this.EntitySchema.Columns.find((c) => c.DataType == 'GUID');
93
71
  const tmpDeletedColumn = this.EntitySchema.Columns.find((c) => c.Column == 'Deleted');
94
72
 
95
73
  if (!tmpGUIDColumn && !tmpDeletedColumn)
96
74
  {
97
75
  this.log.info(`No GUID or Deleted columns for ${this.EntitySchema.TableName}; skipping index creation`);
98
- return fValidateAndCallback(pCreateError);
76
+ return fCallback(pCreateError);
99
77
  }
100
78
 
101
79
  if (!this.fable.MeadowConnectionManager || !this.fable.MeadowConnectionManager.ConnectionPool)
102
80
  {
103
81
  this.log.info(`No connection manager available; skipping index creation for ${this.EntitySchema.TableName}`);
104
- return fValidateAndCallback(pCreateError);
82
+ return fCallback(pCreateError);
105
83
  }
106
84
 
107
85
  let tmpAnticipate = this.fable.newAnticipate();
@@ -121,7 +99,7 @@ class MeadowSyncEntityOngoing extends libFableServiceProviderBase
121
99
  return this.fable.MeadowConnectionManager.createIndex(this.EntitySchema, tmpDeletedColumn, false, fNext);
122
100
  });
123
101
  }
124
- tmpAnticipate.wait((pIndexError) => { return fValidateAndCallback(pCreateError); });
102
+ tmpAnticipate.wait((pIndexError) => { return fCallback(pCreateError); });
125
103
  });
126
104
  }
127
105
  return fCallback();
@@ -170,6 +148,426 @@ class MeadowSyncEntityOngoing extends libFableServiceProviderBase
170
148
  return tmpRecordToCommit;
171
149
  }
172
150
 
151
+ // ---- REST / Local query helpers ----
152
+
153
+ // Format a date value for use in Meadow REST filter expressions (FBV).
154
+ _formatDateForFilter(pDate)
155
+ {
156
+ return this.fable.Dates.dayJS.utc(pDate).format('YYYY-MM-DDTHH:mm:ss.SSS');
157
+ }
158
+
159
+ // Get a count from the remote server, optionally filtered.
160
+ _getServerCount(pFilter, fCallback)
161
+ {
162
+ const tmpURL = pFilter
163
+ ? `${this.EntitySchema.TableName}s/Count/FilteredTo/${pFilter}`
164
+ : `${this.EntitySchema.TableName}s/Count`;
165
+ this.fable.MeadowCloneRestClient.getJSON(tmpURL,
166
+ (pError, pResponse, pBody) =>
167
+ {
168
+ if (pError)
169
+ {
170
+ return fCallback(pError);
171
+ }
172
+ if (pBody && pBody.hasOwnProperty('Count'))
173
+ {
174
+ return fCallback(null, pBody.Count);
175
+ }
176
+ return fCallback(null, 0);
177
+ });
178
+ }
179
+
180
+ // Get a page of records from the remote server with a Meadow filter expression.
181
+ _getServerRecords(pFilter, pOffset, pPageSize, fCallback)
182
+ {
183
+ const tmpURL = `${this.EntitySchema.TableName}s/FilteredTo/${pFilter}/${pOffset}/${pPageSize}`;
184
+ this.fable.MeadowCloneRestClient.getJSON(tmpURL,
185
+ (pError, pResponse, pBody) =>
186
+ {
187
+ if (pError)
188
+ {
189
+ return fCallback(pError);
190
+ }
191
+ if (pBody && Array.isArray(pBody))
192
+ {
193
+ return fCallback(null, pBody);
194
+ }
195
+ return fCallback(null, []);
196
+ });
197
+ }
198
+
199
+ // Get a count from the local database with optional ID range filters.
200
+ _getLocalCount(pMinID, pMaxID, fCallback)
201
+ {
202
+ const tmpQuery = this.Meadow.query;
203
+ if (pMinID > 0)
204
+ {
205
+ tmpQuery.addFilter(this.DefaultIdentifier, pMinID, '>=');
206
+ }
207
+ if (pMaxID > 0)
208
+ {
209
+ tmpQuery.addFilter(this.DefaultIdentifier, pMaxID, '<=');
210
+ }
211
+ if (!this._hasDeletedColumn)
212
+ {
213
+ tmpQuery.setDisableDeleteTracking(true);
214
+ }
215
+ this.Meadow.doCount(tmpQuery,
216
+ (pError, pQuery, pCount) =>
217
+ {
218
+ if (pError)
219
+ {
220
+ return fCallback(pError);
221
+ }
222
+ return fCallback(null, pCount);
223
+ });
224
+ }
225
+
226
+ // Get the max UpdateDate from local records in an ID range.
227
+ _getLocalMaxUpdateDate(pMinID, pMaxID, fCallback)
228
+ {
229
+ const tmpQuery = this.Meadow.query;
230
+ if (pMinID > 0)
231
+ {
232
+ tmpQuery.addFilter(this.DefaultIdentifier, pMinID, '>=');
233
+ }
234
+ if (pMaxID > 0)
235
+ {
236
+ tmpQuery.addFilter(this.DefaultIdentifier, pMaxID, '<=');
237
+ }
238
+ tmpQuery.setSort({ Column: 'UpdateDate', Direction: 'Descending' });
239
+ tmpQuery.setCap(1);
240
+ if (!this._hasDeletedColumn)
241
+ {
242
+ tmpQuery.setDisableDeleteTracking(true);
243
+ }
244
+ this.Meadow.doRead(tmpQuery,
245
+ (pError, pQuery, pRecord) =>
246
+ {
247
+ if (pError)
248
+ {
249
+ return fCallback(pError);
250
+ }
251
+ if (!pRecord || !pRecord.UpdateDate)
252
+ {
253
+ return fCallback(null, false);
254
+ }
255
+ return fCallback(null, pRecord.UpdateDate);
256
+ });
257
+ }
258
+
259
+ // Get the min UpdateDate from local records in an ID range.
260
+ _getLocalMinUpdateDate(pMinID, pMaxID, fCallback)
261
+ {
262
+ const tmpQuery = this.Meadow.query;
263
+ if (pMinID > 0)
264
+ {
265
+ tmpQuery.addFilter(this.DefaultIdentifier, pMinID, '>=');
266
+ }
267
+ if (pMaxID > 0)
268
+ {
269
+ tmpQuery.addFilter(this.DefaultIdentifier, pMaxID, '<=');
270
+ }
271
+ tmpQuery.setSort({ Column: 'UpdateDate', Direction: 'Ascending' });
272
+ tmpQuery.setCap(1);
273
+ if (!this._hasDeletedColumn)
274
+ {
275
+ tmpQuery.setDisableDeleteTracking(true);
276
+ }
277
+ this.Meadow.doRead(tmpQuery,
278
+ (pError, pQuery, pRecord) =>
279
+ {
280
+ if (pError)
281
+ {
282
+ return fCallback(pError);
283
+ }
284
+ if (!pRecord || !pRecord.UpdateDate)
285
+ {
286
+ return fCallback(null, false);
287
+ }
288
+ return fCallback(null, pRecord.UpdateDate);
289
+ });
290
+ }
291
+
292
+ // Upsert a single record from the server into the local database.
293
+ _upsertRecord(pServerRecord, fCallback)
294
+ {
295
+ const tmpRecordToCommit = this.marshalRecord(pServerRecord);
296
+
297
+ const tmpQuery = this.Meadow.query;
298
+ tmpQuery.addFilter(this.DefaultIdentifier, pServerRecord[this.DefaultIdentifier]);
299
+ if (!this._hasDeletedColumn)
300
+ {
301
+ tmpQuery.setDisableDeleteTracking(true);
302
+ }
303
+
304
+ this.Meadow.doRead(tmpQuery,
305
+ (pReadError, pQuery, pLocalRecord) =>
306
+ {
307
+ if (pReadError)
308
+ {
309
+ this.fable.log.error(`Error reading local record ${this.EntitySchema.TableName} ID ${pServerRecord[this.DefaultIdentifier]}: ${pReadError}`);
310
+ return fCallback();
311
+ }
312
+
313
+ const tmpSyncQuery = this.Meadow.query.addRecord(tmpRecordToCommit);
314
+ tmpSyncQuery.setDisableAutoIdentity(true);
315
+ tmpSyncQuery.setDisableAutoDateStamp(true);
316
+ tmpSyncQuery.setDisableAutoUserStamp(true);
317
+ tmpSyncQuery.setDisableDeleteTracking(true);
318
+
319
+ if (!pLocalRecord)
320
+ {
321
+ // Record does not exist locally -- create
322
+ tmpSyncQuery.AllowIdentityInsert = true;
323
+ this.Meadow.doCreate(tmpSyncQuery,
324
+ (pCreateError) =>
325
+ {
326
+ if (pCreateError)
327
+ {
328
+ let tmpErrorStr = (typeof(pCreateError) === 'string') ? pCreateError : JSON.stringify(pCreateError);
329
+ if (tmpErrorStr.toLowerCase().indexOf('duplicate') > -1 || tmpErrorStr.toLowerCase().indexOf('unique') > -1)
330
+ {
331
+ // GUID conflict -- fall back to update
332
+ this.log.warn(`Duplicate key on create for ${this.EntitySchema.TableName} ID ${pServerRecord[this.DefaultIdentifier]}; falling back to update.`);
333
+ const tmpFallbackQuery = this.Meadow.query.addRecord(tmpRecordToCommit);
334
+ tmpFallbackQuery.setDisableAutoIdentity(true);
335
+ tmpFallbackQuery.setDisableAutoDateStamp(true);
336
+ tmpFallbackQuery.setDisableAutoUserStamp(true);
337
+ tmpFallbackQuery.setDisableDeleteTracking(true);
338
+ this.Meadow.doUpdate(tmpFallbackQuery,
339
+ (pUpdateError) =>
340
+ {
341
+ if (pUpdateError)
342
+ {
343
+ this.log.error(`Fallback update also failed for ${this.EntitySchema.TableName} ID ${pServerRecord[this.DefaultIdentifier]}: ${pUpdateError}`);
344
+ }
345
+ return fCallback();
346
+ });
347
+ return;
348
+ }
349
+ this.log.error(`Error creating record ${this.EntitySchema.TableName}: ${pCreateError}`, pCreateError);
350
+ return fCallback();
351
+ }
352
+ return fCallback();
353
+ });
354
+ }
355
+ else
356
+ {
357
+ // Record exists locally -- update
358
+ this.Meadow.doUpdate(tmpSyncQuery,
359
+ (pUpdateError) =>
360
+ {
361
+ if (pUpdateError)
362
+ {
363
+ this.log.error(`Error updating record ${this.EntitySchema.TableName}: ${pUpdateError}`, pUpdateError);
364
+ }
365
+ return fCallback();
366
+ });
367
+ }
368
+ });
369
+ }
370
+
371
+ // Pull all records from server matching a filter expression and upsert them locally.
372
+ // Fetches in pages of this.PageSize.
373
+ _pullServerRecords(pFilter, pEstimatedCount, fCallback)
374
+ {
375
+ if (pEstimatedCount < 1)
376
+ {
377
+ return fCallback(null, 0);
378
+ }
379
+
380
+ let tmpSyncedCount = 0;
381
+ let tmpOffset = 0;
382
+ let tmpDone = false;
383
+
384
+ let tmpRecordCap = (this.MaxRecordsPerEntity > 0)
385
+ ? Math.min(pEstimatedCount, this.MaxRecordsPerEntity)
386
+ : pEstimatedCount;
387
+
388
+ const fFetchPage = () =>
389
+ {
390
+ if (tmpDone || tmpOffset >= tmpRecordCap)
391
+ {
392
+ return fCallback(null, tmpSyncedCount);
393
+ }
394
+
395
+ this._getServerRecords(pFilter, tmpOffset, this.PageSize,
396
+ (pError, pRecords) =>
397
+ {
398
+ if (pError)
399
+ {
400
+ this.fable.log.error(`Error fetching ${this.EntitySchema.TableName} page at offset ${tmpOffset}: ${pError}`);
401
+ return fCallback(pError, tmpSyncedCount);
402
+ }
403
+ if (!pRecords || pRecords.length < 1)
404
+ {
405
+ tmpDone = true;
406
+ return fCallback(null, tmpSyncedCount);
407
+ }
408
+
409
+ this.fable.Utility.eachLimit(pRecords, 5,
410
+ (pRecord, fRecordDone) =>
411
+ {
412
+ this._upsertRecord(pRecord,
413
+ () =>
414
+ {
415
+ tmpSyncedCount++;
416
+ return fRecordDone();
417
+ });
418
+ },
419
+ (pUpsertError) =>
420
+ {
421
+ tmpOffset += this.PageSize;
422
+ if (pRecords.length < this.PageSize)
423
+ {
424
+ tmpDone = true;
425
+ return fCallback(null, tmpSyncedCount);
426
+ }
427
+ this.fable.log.info(`${this.EntitySchema.TableName}: pulled ${tmpSyncedCount} of ~${tmpRecordCap} records...`);
428
+ return fFetchPage();
429
+ });
430
+ });
431
+ };
432
+
433
+ fFetchPage();
434
+ }
435
+
436
+ // ---- Bisection logic ----
437
+
438
+ // Compare a local ID range against the server. If counts or date boundaries
439
+ // differ, subdivide until the range is small enough, then pull all records in
440
+ // the range from the server to bring local in sync.
441
+ _bisectRange(pMinID, pMaxID, pDepth, fCallback)
442
+ {
443
+ const tmpRangeSize = pMaxID - pMinID + 1;
444
+ const tmpIDCol = this.DefaultIdentifier;
445
+ const tmpRangeFilter = `FBV~${tmpIDCol}~GE~${pMinID}~FBV~${tmpIDCol}~LE~${pMaxID}`;
446
+
447
+ // Get local stats for this range
448
+ this._getLocalCount(pMinID, pMaxID,
449
+ (pLocalCountError, pLocalCount) =>
450
+ {
451
+ if (pLocalCountError)
452
+ {
453
+ this.fable.log.warn(`${this.EntitySchema.TableName}: bisect local count error for range ${pMinID}-${pMaxID}: ${pLocalCountError}`);
454
+ return fCallback();
455
+ }
456
+
457
+ // Get server count for this range
458
+ this._getServerCount(tmpRangeFilter,
459
+ (pServerCountError, pServerCount) =>
460
+ {
461
+ if (pServerCountError)
462
+ {
463
+ this.fable.log.warn(`${this.EntitySchema.TableName}: bisect server count error for range ${pMinID}-${pMaxID}: ${pServerCountError}`);
464
+ return fCallback();
465
+ }
466
+
467
+ // If counts match, check UpdateDate boundaries for this range
468
+ if (pLocalCount === pServerCount)
469
+ {
470
+ if (!this._hasUpdateDate)
471
+ {
472
+ // No UpdateDate column -- counts match, assume in sync
473
+ return fCallback();
474
+ }
475
+
476
+ // Compare max and min UpdateDate for this range
477
+ this._getLocalMaxUpdateDate(pMinID, pMaxID,
478
+ (pLocalMaxErr, pLocalMaxDate) =>
479
+ {
480
+ if (pLocalMaxErr || !pLocalMaxDate)
481
+ {
482
+ return fCallback();
483
+ }
484
+
485
+ // Get server max UpdateDate for this range (1 record, sorted desc)
486
+ const tmpMaxDateFilter = `${tmpRangeFilter}~FSF~UpdateDate~DESC~DESC`;
487
+ this._getServerRecords(tmpMaxDateFilter, 0, 1,
488
+ (pServerMaxErr, pServerMaxRecords) =>
489
+ {
490
+ if (pServerMaxErr || !pServerMaxRecords || pServerMaxRecords.length < 1)
491
+ {
492
+ return fCallback();
493
+ }
494
+
495
+ const tmpServerMaxDate = pServerMaxRecords[0].UpdateDate;
496
+ const tmpMaxDateDiff = Math.abs(this.fable.Dates.dayJS.utc(pLocalMaxDate).diff(this.fable.Dates.dayJS.utc(tmpServerMaxDate)));
497
+
498
+ if (tmpMaxDateDiff < 5)
499
+ {
500
+ // Max dates match and counts match -- this range is in sync
501
+ return fCallback();
502
+ }
503
+
504
+ // Dates differ even though counts match -- records have been modified.
505
+ // If range is small enough, pull all records; otherwise subdivide.
506
+ this.fable.log.info(`${this.EntitySchema.TableName}: date mismatch in range ${pMinID}-${pMaxID} (local max: ${pLocalMaxDate}, server max: ${tmpServerMaxDate})`);
507
+ if (tmpRangeSize <= this.BisectMinRangeSize)
508
+ {
509
+ return this._pullRangeFromServer(pMinID, pMaxID, fCallback);
510
+ }
511
+ return this._subdivideRange(pMinID, pMaxID, pDepth, fCallback);
512
+ });
513
+ });
514
+ return;
515
+ }
516
+
517
+ // Counts differ
518
+ this.fable.log.info(`${this.EntitySchema.TableName}: count mismatch in range ${pMinID}-${pMaxID} (local: ${pLocalCount}, server: ${pServerCount})`);
519
+
520
+ if (tmpRangeSize <= this.BisectMinRangeSize)
521
+ {
522
+ return this._pullRangeFromServer(pMinID, pMaxID, fCallback);
523
+ }
524
+
525
+ return this._subdivideRange(pMinID, pMaxID, pDepth, fCallback);
526
+ });
527
+ });
528
+ }
529
+
530
+ // Split an ID range in half and bisect each half.
531
+ _subdivideRange(pMinID, pMaxID, pDepth, fCallback)
532
+ {
533
+ const tmpMidID = Math.floor((pMinID + pMaxID) / 2);
534
+
535
+ this.fable.log.info(`${this.EntitySchema.TableName}: subdividing range ${pMinID}-${pMaxID} at ID ${tmpMidID} (depth ${pDepth})`);
536
+
537
+ // Bisect lower half, then upper half
538
+ this._bisectRange(pMinID, tmpMidID, pDepth + 1,
539
+ () =>
540
+ {
541
+ this._bisectRange(tmpMidID + 1, pMaxID, pDepth + 1, fCallback);
542
+ });
543
+ }
544
+
545
+ // Pull all records from the server in an ID range and upsert them locally.
546
+ _pullRangeFromServer(pMinID, pMaxID, fCallback)
547
+ {
548
+ const tmpIDCol = this.DefaultIdentifier;
549
+ const tmpFilter = `FBV~${tmpIDCol}~GE~${pMinID}~FBV~${tmpIDCol}~LE~${pMaxID}~FSF~${tmpIDCol}~ASC~ASC`;
550
+ const tmpEstimatedCount = pMaxID - pMinID + 1;
551
+
552
+ this.fable.log.info(`${this.EntitySchema.TableName}: pulling range ${pMinID}-${pMaxID} from server (~${tmpEstimatedCount} records)`);
553
+
554
+ this._pullServerRecords(tmpFilter, tmpEstimatedCount,
555
+ (pError, pSyncedCount) =>
556
+ {
557
+ if (pError)
558
+ {
559
+ this.fable.log.warn(`${this.EntitySchema.TableName}: error pulling range ${pMinID}-${pMaxID}: ${pError}`);
560
+ }
561
+ else
562
+ {
563
+ this.fable.log.info(`${this.EntitySchema.TableName}: synced ${pSyncedCount} records in range ${pMinID}-${pMaxID}`);
564
+ }
565
+ return fCallback();
566
+ });
567
+ }
568
+
569
+ // ---- Deleted records sync ----
570
+
173
571
  syncDeletedRecords(fCallback)
174
572
  {
175
573
  const tmpDeletedColumn = this.EntitySchema.Columns.find((c) => c.Column == 'Deleted');
@@ -181,7 +579,6 @@ class MeadowSyncEntityOngoing extends libFableServiceProviderBase
181
579
 
182
580
  this.fable.log.info(`Checking for deleted records on server for ${this.EntitySchema.TableName}...`);
183
581
 
184
- // Get the count of deleted records from the server.
185
582
  // The explicit FBV~Deleted~EQ~1 filter overrides foxhound's automatic Deleted=0 filter.
186
583
  this.fable.MeadowCloneRestClient.getJSON(`${this.EntitySchema.TableName}s/Count/FilteredTo/FBV~Deleted~EQ~1`,
187
584
  (pError, pResponse, pBody) =>
@@ -303,149 +700,7 @@ class MeadowSyncEntityOngoing extends libFableServiceProviderBase
303
700
  });
304
701
  }
305
702
 
306
- addSyncAnticipateEntry(tmpSyncState, tmpAnticipate)
307
- {
308
- tmpAnticipate.anticipate(
309
- (fNext) =>
310
- {
311
- const tmpURLPartial = `${this.EntitySchema.TableName}s/FilteredTo/FBV~${this.DefaultIdentifier}~GT~${tmpSyncState.LastRequestedID}~FSF~${this.DefaultIdentifier}~ASC~ASC/0/${this.PageSize}`;
312
- this.fable.MeadowCloneRestClient.getJSON(tmpURLPartial,
313
- (pDownloadError, pResponse, pBody) =>
314
- {
315
- if (pDownloadError)
316
- {
317
- this.fable.log.error(`Error getting URL Partial [${tmpURLPartial}]: ${pDownloadError}`, { Error: pDownloadError });
318
- return fNext();
319
- }
320
- if (pBody && Array.isArray(pBody) && pBody.length > 0)
321
- {
322
- for (let i = 0; i < pBody.length; i++)
323
- {
324
- const tmpRecord = pBody[i];
325
-
326
- tmpAnticipate.anticipate(
327
- (fNextEntityRecordSync) =>
328
- {
329
- const tmpQuery = this.Meadow.query;
330
-
331
- if (tmpRecord[this.DefaultIdentifier] > tmpSyncState.LastRequestedID)
332
- {
333
- tmpSyncState.LastRequestedID = tmpRecord[this.DefaultIdentifier];
334
- }
335
-
336
- if ((typeof(tmpRecord[this.DefaultIdentifier]) !== 'undefined') && (tmpRecord[this.DefaultIdentifier] > 0))
337
- {
338
- tmpQuery.addFilter(this.DefaultIdentifier, tmpRecord[this.DefaultIdentifier]);
339
- }
340
-
341
- if (!tmpSyncState.HasDeletedColumn)
342
- {
343
- tmpQuery.setDisableDeleteTracking(true);
344
- }
345
-
346
- this.Meadow.doRead(tmpQuery,
347
- (pReadError, pQuery, pRecord) =>
348
- {
349
- if (pReadError)
350
- {
351
- this.fable.log.error(`Error reading record ${this.EntitySchema.TableName}: ${pReadError}`, { Error: pReadError, PassedRecord: tmpRecord });
352
- return fNextEntityRecordSync();
353
- }
354
-
355
- if (pRecord)
356
- {
357
- const tmpAgeDifference = this.fable.Dates.dayJS(tmpRecord.UpdateDate).diff(this.fable.Dates.dayJS(pRecord.UpdateDate));
358
-
359
- if (Math.abs(tmpAgeDifference) < 5)
360
- {
361
- return fNextEntityRecordSync();
362
- }
363
-
364
- this.fable.log.info(`Syncing ${this.EntitySchema.TableName} record ${tmpRecord[this.DefaultIdentifier]} with age difference of ${tmpAgeDifference} ms.`);
365
- }
366
-
367
- const tmpRecordToCommit = this.marshalRecord(tmpRecord);
368
-
369
- const tmpSyncQuery = this.Meadow.query.addRecord(tmpRecordToCommit);
370
-
371
- tmpSyncQuery.setDisableAutoIdentity(true);
372
- tmpSyncQuery.setDisableAutoDateStamp(true);
373
- tmpSyncQuery.setDisableAutoUserStamp(true);
374
- tmpSyncQuery.setDisableDeleteTracking(true);
375
-
376
- if (!pRecord)
377
- {
378
- // Record not found -- create
379
- tmpSyncQuery.AllowIdentityInsert = true;
380
-
381
- this.Meadow.doCreate(tmpSyncQuery,
382
- (pCreateError) =>
383
- {
384
- if (pCreateError)
385
- {
386
- let tmpErrorStr = (typeof(pCreateError) === 'string') ? pCreateError : JSON.stringify(pCreateError);
387
- if (tmpErrorStr.toLowerCase().indexOf('duplicate') > -1 || tmpErrorStr.toLowerCase().indexOf('unique') > -1)
388
- {
389
- // Duplicate key (likely GUID conflict) -- fall back to update
390
- this.log.warn(`Duplicate key on create for ${this.EntitySchema.TableName} ID ${tmpRecord[this.DefaultIdentifier]}; falling back to update.`);
391
- const tmpFallbackQuery = this.Meadow.query.addRecord(tmpRecordToCommit);
392
- tmpFallbackQuery.setDisableAutoIdentity(true);
393
- tmpFallbackQuery.setDisableAutoDateStamp(true);
394
- tmpFallbackQuery.setDisableAutoUserStamp(true);
395
- tmpFallbackQuery.setDisableDeleteTracking(true);
396
- this.Meadow.doUpdate(tmpFallbackQuery,
397
- (pUpdateError) =>
398
- {
399
- if (pUpdateError)
400
- {
401
- this.log.error(`Fallback update also failed for ${this.EntitySchema.TableName} ID ${tmpRecord[this.DefaultIdentifier]}: ${pUpdateError}`);
402
- return fNextEntityRecordSync();
403
- }
404
- this.operation.incrementProgressTrackerStatus(`UpdateSync-${this.EntitySchema.TableName}`, 1);
405
- return fNextEntityRecordSync();
406
- });
407
- return;
408
- }
409
- this.log.error(`Error creating record ${this.EntitySchema.TableName}: ${pCreateError}`, pCreateError);
410
- return fNextEntityRecordSync();
411
- }
412
- this.operation.incrementProgressTrackerStatus(`UpdateSync-${this.EntitySchema.TableName}`, 1);
413
- return fNextEntityRecordSync();
414
- });
415
- }
416
- else
417
- {
418
- // Record found -- update
419
- this.Meadow.doUpdate(tmpSyncQuery,
420
- (pUpdateError) =>
421
- {
422
- if (pUpdateError)
423
- {
424
- this.log.error(`Error updating record ${this.EntitySchema.TableName}: ${pUpdateError}`, pUpdateError);
425
- return fNextEntityRecordSync();
426
- }
427
- this.operation.incrementProgressTrackerStatus(`UpdateSync-${this.EntitySchema.TableName}`, 1);
428
- return fNextEntityRecordSync();
429
- });
430
- }
431
- });
432
- });
433
- }
434
- tmpSyncState.RequestsPerformed++;
435
- if (tmpSyncState.RequestsPerformed < tmpSyncState.EstimatedRequestCount)
436
- {
437
- this.fable.log.info(`Syncing ${this.EntitySchema.TableName} request ${tmpSyncState.RequestsPerformed} of ${tmpSyncState.EstimatedRequestCount}...`);
438
- this.addSyncAnticipateEntry(tmpSyncState, tmpAnticipate);
439
- }
440
- return fNext();
441
- }
442
- else
443
- {
444
- return fNext();
445
- }
446
- });
447
- });
448
- }
703
+ // ---- Main sync entry point ----
449
704
 
450
705
  sync(fCallback)
451
706
  {
@@ -455,55 +710,69 @@ class MeadowSyncEntityOngoing extends libFableServiceProviderBase
455
710
  return fCallback();
456
711
  }
457
712
 
458
- this.operation.createTimeStamp('EntityOngoingSync');
713
+ // Validate local table schema with a lightweight read before syncing
714
+ const tmpValidationQuery = this.Meadow.query;
715
+ tmpValidationQuery.setSort({ Column: this.DefaultIdentifier, Direction: 'Descending' });
716
+ tmpValidationQuery.setCap(1);
717
+ tmpValidationQuery.setDisableDeleteTracking(true);
718
+ this.Meadow.doRead(tmpValidationQuery,
719
+ (pReadError) =>
720
+ {
721
+ if (pReadError)
722
+ {
723
+ let tmpErrorStr = (typeof(pReadError) === 'string') ? pReadError : JSON.stringify(pReadError);
724
+ if (tmpErrorStr.indexOf('Invalid column') > -1 || tmpErrorStr.indexOf('Invalid object') > -1 || tmpErrorStr.indexOf('no such column') > -1 || tmpErrorStr.indexOf('no such table') > -1)
725
+ {
726
+ this.log.warn(`${this.EntitySchema.TableName}: local table schema mismatch (${pReadError}); skipping sync.`);
727
+ return fCallback();
728
+ }
729
+ }
730
+ return this._syncInternal(fCallback);
731
+ });
732
+ }
459
733
 
460
- let tmpAnticipate = this.fable.newAnticipate();
734
+ _syncInternal(fCallback)
735
+ {
736
+ this.operation.createTimeStamp('EntityOngoingSync');
461
737
 
462
738
  const tmpSyncState = (
463
739
  {
464
- Local: { MaxIDEntity: -1, RecordCount: 0, HasUpdateDate: false, LatestUpdateDate: false },
465
- Server: { MaxIDEntity: -1, RecordCount: 0, HasUpdateDate: false, LatestUpdateDate: false },
740
+ Local: { MaxIDEntity: -1, RecordCount: 0 },
741
+ Server: { MaxIDEntity: -1, RecordCount: 0 },
466
742
  });
467
743
 
468
- this.fable.Utility.waterfall(
469
- [
470
- (fStageComplete) =>
471
- {
472
- if (!this.EntitySchema || !this.EntitySchema.MeadowSchema || !Array.isArray(this.EntitySchema.MeadowSchema.Schema))
473
- {
474
- return fStageComplete('MeadowSyncEntityOngoing requires a valid MeadowEntitySchema.MeadowSchema.Schema.');
475
- }
744
+ // Detect schema capabilities
745
+ this._hasUpdateDate = false;
746
+ this._hasDeletedColumn = false;
476
747
 
477
- for (let i = 0; i < this.EntitySchema.MeadowSchema.Schema.length; i++)
478
- {
479
- const tmpColumn = this.EntitySchema.MeadowSchema.Schema[i];
480
- if (tmpColumn.Column == 'UpdateDate')
481
- {
482
- tmpSyncState.Local.HasUpdateDate = true;
483
- tmpSyncState.Server.HasUpdateDate = true;
484
- }
485
- if (tmpColumn.Type == 'Deleted' || tmpColumn.Column == 'Deleted')
486
- {
487
- tmpSyncState.HasDeletedColumn = true;
488
- }
489
- }
748
+ if (this.EntitySchema && this.EntitySchema.MeadowSchema && Array.isArray(this.EntitySchema.MeadowSchema.Schema))
749
+ {
750
+ for (let i = 0; i < this.EntitySchema.MeadowSchema.Schema.length; i++)
751
+ {
752
+ const tmpColumn = this.EntitySchema.MeadowSchema.Schema[i];
753
+ if (tmpColumn.Column == 'UpdateDate')
754
+ {
755
+ this._hasUpdateDate = true;
756
+ }
757
+ if (tmpColumn.Type == 'Deleted' || tmpColumn.Column == 'Deleted')
758
+ {
759
+ this._hasDeletedColumn = true;
760
+ }
761
+ }
762
+ }
490
763
 
491
- if (tmpSyncState.Local.HasUpdateDate)
492
- {
493
- this.log.info(`Entity ${this.EntitySchema.TableName} has UpdateDate column.`);
494
- }
764
+ this.fable.log.info(`Syncing with ONGOING STRATEGY entity ${this.EntitySchema.TableName} (UpdateDate: ${this._hasUpdateDate}, Deleted: ${this._hasDeletedColumn})...`);
495
765
 
496
- this.log.info(`Syncing with UPDATE STRATEGY entity ${this.EntitySchema.TableName}...`);
497
- return fStageComplete();
498
- },
766
+ this.fable.Utility.waterfall(
767
+ [
768
+ // ---- Stage 1: Gather local stats ----
499
769
  (fStageComplete) =>
500
770
  {
501
- // Get the Max ID from local database
771
+ // Local max ID
502
772
  const tmpQuery = this.Meadow.query;
503
773
  tmpQuery.setSort({ Column: this.DefaultIdentifier, Direction: 'Descending' });
504
774
  tmpQuery.setCap(1);
505
- // Disable delete tracking if the table has no Deleted column
506
- if (!tmpSyncState.HasDeletedColumn)
775
+ if (!this._hasDeletedColumn)
507
776
  {
508
777
  tmpQuery.setDisableDeleteTracking(true);
509
778
  }
@@ -512,31 +781,32 @@ class MeadowSyncEntityOngoing extends libFableServiceProviderBase
512
781
  {
513
782
  if (pReadError)
514
783
  {
515
- this.fable.log.error(`Error reading local max entity ID ${this.EntitySchema.TableName}: ${pReadError}`, { Error: pReadError });
784
+ this.fable.log.error(`Error reading local max entity ID ${this.EntitySchema.TableName}: ${pReadError}`);
516
785
  return fStageComplete(`Error reading local max entity ID ${this.EntitySchema.TableName}: ${pReadError}`);
517
786
  }
518
- if (!pRecord)
787
+ if (pRecord)
519
788
  {
520
- this.fable.log.warn(`No records found in local ${this.EntitySchema.TableName}.`);
521
- return fStageComplete();
789
+ tmpSyncState.Local.MaxIDEntity = pRecord[this.DefaultIdentifier];
790
+ this.fable.log.info(`Found local max entity ID ${this.EntitySchema.TableName}: ${tmpSyncState.Local.MaxIDEntity}`);
791
+ }
792
+ else
793
+ {
794
+ this.fable.log.info(`No local records for ${this.EntitySchema.TableName}.`);
522
795
  }
523
- this.fable.log.info(`Found local max entity ID ${this.EntitySchema.TableName}: ${pRecord[this.DefaultIdentifier]}`);
524
- tmpSyncState.Local.MaxIDEntity = pRecord[this.DefaultIdentifier];
525
796
  return fStageComplete();
526
797
  });
527
798
  },
528
799
  (fStageComplete) =>
529
800
  {
530
- // Get the Max UpdateDate from local database — skip if table has no UpdateDate column
531
- if (!tmpSyncState.Local.HasUpdateDate)
801
+ // Local max UpdateDate
802
+ if (!this._hasUpdateDate)
532
803
  {
533
- this.fable.log.info(`No UpdateDate column for ${this.EntitySchema.TableName}; skipping UpdateDate check.`);
534
804
  return fStageComplete();
535
805
  }
536
806
  const tmpQuery = this.Meadow.query;
537
807
  tmpQuery.setSort({ Column: 'UpdateDate', Direction: 'Descending' });
538
808
  tmpQuery.setCap(1);
539
- if (!tmpSyncState.HasDeletedColumn)
809
+ if (!this._hasDeletedColumn)
540
810
  {
541
811
  tmpQuery.setDisableDeleteTracking(true);
542
812
  }
@@ -545,24 +815,22 @@ class MeadowSyncEntityOngoing extends libFableServiceProviderBase
545
815
  {
546
816
  if (pReadError)
547
817
  {
548
- this.fable.log.error(`Error reading local max UpdateDate ${this.EntitySchema.TableName}: ${pReadError}`, { Error: pReadError });
818
+ this.fable.log.error(`Error reading local max UpdateDate ${this.EntitySchema.TableName}: ${pReadError}`);
549
819
  return fStageComplete(`Error reading local max UpdateDate ${this.EntitySchema.TableName}: ${pReadError}`);
550
820
  }
551
- if (!pRecord)
821
+ if (pRecord && pRecord.UpdateDate)
552
822
  {
553
- this.fable.log.warn(`No records found in local checking UpdateDate ${this.EntitySchema.TableName}.`);
554
- return fStageComplete();
823
+ tmpSyncState.Local.MaxUpdateDate = pRecord.UpdateDate;
824
+ this.fable.log.info(`Found local max UpdateDate ${this.EntitySchema.TableName}: ${tmpSyncState.Local.MaxUpdateDate}`);
555
825
  }
556
- this.fable.log.info(`Found local max UpdateDate ${this.EntitySchema.TableName}: ${pRecord.UpdateDate}`);
557
- tmpSyncState.Local.MaxUpdateDate = pRecord.UpdateDate;
558
826
  return fStageComplete();
559
827
  });
560
828
  },
561
829
  (fStageComplete) =>
562
830
  {
563
- // Get the count from local database
831
+ // Local count
564
832
  const tmpQuery = this.Meadow.query;
565
- if (!tmpSyncState.HasDeletedColumn)
833
+ if (!this._hasDeletedColumn)
566
834
  {
567
835
  tmpQuery.setDisableDeleteTracking(true);
568
836
  }
@@ -571,16 +839,19 @@ class MeadowSyncEntityOngoing extends libFableServiceProviderBase
571
839
  {
572
840
  if (pCountError)
573
841
  {
574
- this.fable.log.error(`Error getting local count of ${this.EntitySchema.TableName}: ${pCountError}`, { Error: pCountError });
842
+ this.fable.log.error(`Error getting local count of ${this.EntitySchema.TableName}: ${pCountError}`);
575
843
  return fStageComplete(`Error getting local count of ${this.EntitySchema.TableName}: ${pCountError}`);
576
844
  }
577
845
  tmpSyncState.Local.RecordCount = pCount;
846
+ this.fable.log.info(`Local count ${this.EntitySchema.TableName}: ${tmpSyncState.Local.RecordCount}`);
578
847
  return fStageComplete();
579
848
  });
580
849
  },
850
+
851
+ // ---- Stage 2: Gather server stats ----
581
852
  (fStageComplete) =>
582
853
  {
583
- // Get the Max ID from server
854
+ // Server max ID
584
855
  this.fable.MeadowCloneRestClient.getJSON(`${this.EntitySchema.TableName}/Max/${this.DefaultIdentifier}`,
585
856
  (pError, pResponse, pBody) =>
586
857
  {
@@ -591,8 +862,8 @@ class MeadowSyncEntityOngoing extends libFableServiceProviderBase
591
862
  }
592
863
  if (pBody && pBody.hasOwnProperty(this.DefaultIdentifier))
593
864
  {
594
- this.fable.log.info(`Found server max entity ID ${this.EntitySchema.TableName}: ${pBody[this.DefaultIdentifier]}`);
595
865
  tmpSyncState.Server.MaxIDEntity = pBody[this.DefaultIdentifier];
866
+ this.fable.log.info(`Found server max entity ID ${this.EntitySchema.TableName}: ${tmpSyncState.Server.MaxIDEntity}`);
596
867
  }
597
868
  else
598
869
  {
@@ -603,85 +874,192 @@ class MeadowSyncEntityOngoing extends libFableServiceProviderBase
603
874
  },
604
875
  (fStageComplete) =>
605
876
  {
606
- // Get the Max UpdateDate from server
607
- this.fable.MeadowCloneRestClient.getJSON(`${this.EntitySchema.TableName}/Max/UpdateDate`,
608
- (pError, pResponse, pBody) =>
877
+ // Server count
878
+ this._getServerCount(null,
879
+ (pError, pCount) =>
609
880
  {
610
881
  if (pError)
611
882
  {
612
- this.fable.log.warn(`Could not get server max UpdateDate for ${this.EntitySchema.TableName} (${pError}); will sync by ID only.`);
883
+ this.fable.log.warn(`Could not get server count for ${this.EntitySchema.TableName} (${pError}); estimating from max ID.`);
884
+ tmpSyncState.Server.RecordCount = tmpSyncState.Server.MaxIDEntity > 0 ? tmpSyncState.Server.MaxIDEntity : 0;
613
885
  return fStageComplete();
614
886
  }
615
- if (pBody && pBody.hasOwnProperty(this.DefaultIdentifier))
616
- {
617
- this.fable.log.info(`Found server max UpdateDate ${this.EntitySchema.TableName}: ${pBody['UpdateDate']}`);
618
- tmpSyncState.Server.MaxUpdateDate = pBody.UpdateDate;
619
- }
620
- else
621
- {
622
- this.fable.log.warn(`No records found in server for max UpdateDate of ${this.EntitySchema.TableName}.`);
623
- }
887
+ tmpSyncState.Server.RecordCount = pCount;
888
+ this.fable.log.info(`Server count ${this.EntitySchema.TableName}: ${tmpSyncState.Server.RecordCount}`);
624
889
  return fStageComplete();
625
890
  });
626
891
  },
892
+
893
+ // ---- Stage 3: UpdateDate-based fast sync ----
894
+ // If we have UpdateDate, compare server record count up to our local
895
+ // max UpdateDate. If it matches local count, existing records are in
896
+ // sync and we only need to pull records newer than that date.
627
897
  (fStageComplete) =>
628
898
  {
629
- // Get the count from server
630
- this.fable.MeadowCloneRestClient.getJSON(`${this.EntitySchema.TableName}s/Count`,
631
- (pError, pResponse, pBody) =>
899
+ if (!this._hasUpdateDate || !tmpSyncState.Local.MaxUpdateDate)
900
+ {
901
+ this.fable.log.info(`${this.EntitySchema.TableName}: no UpdateDate available; skipping UpdateDate fast-sync.`);
902
+ tmpSyncState.UpdateDateSyncDone = false;
903
+ return fStageComplete();
904
+ }
905
+
906
+ const tmpDateStr = this._formatDateForFilter(tmpSyncState.Local.MaxUpdateDate);
907
+ const tmpIDCol = this.DefaultIdentifier;
908
+ const tmpBeforeFilter = `FBV~UpdateDate~LE~${tmpDateStr}`;
909
+
910
+ this.fable.log.info(`${this.EntitySchema.TableName}: checking server count with UpdateDate <= ${tmpDateStr}...`);
911
+
912
+ this._getServerCount(tmpBeforeFilter,
913
+ (pError, pServerCountBefore) =>
632
914
  {
633
915
  if (pError)
634
916
  {
635
- this.fable.log.warn(`Could not get server count for ${this.EntitySchema.TableName} (${pError}); estimating from max ID.`);
636
- tmpSyncState.Server.RecordCount = tmpSyncState.Server.MaxIDEntity > 0 ? tmpSyncState.Server.MaxIDEntity : 0;
917
+ this.fable.log.warn(`${this.EntitySchema.TableName}: could not get server count before UpdateDate (${pError}); falling back to bisection.`);
918
+ tmpSyncState.UpdateDateSyncDone = false;
637
919
  return fStageComplete();
638
920
  }
639
- if (pBody && pBody.hasOwnProperty('Count'))
921
+
922
+ this.fable.log.info(`${this.EntitySchema.TableName}: server has ${pServerCountBefore} records with UpdateDate <= ${tmpDateStr} (local has ${tmpSyncState.Local.RecordCount})`);
923
+
924
+ if (pServerCountBefore === tmpSyncState.Local.RecordCount)
640
925
  {
641
- this.fable.log.info(`Found server count for ${this.EntitySchema.TableName}: ${pBody.Count}`);
642
- tmpSyncState.Server.RecordCount = pBody.Count;
926
+ // Record counts match up to our max UpdateDate -- existing records are in sync.
927
+ this.fable.log.info(`${this.EntitySchema.TableName}: counts match up to local max UpdateDate; existing records appear in sync.`);
928
+ tmpSyncState.ExistingRecordsInSync = true;
643
929
  }
644
930
  else
645
931
  {
646
- this.fable.log.warn(`No records found in server based on count for ${this.EntitySchema.TableName}.`);
932
+ this.fable.log.info(`${this.EntitySchema.TableName}: count mismatch before max UpdateDate (local: ${tmpSyncState.Local.RecordCount}, server: ${pServerCountBefore}); will bisect existing records.`);
933
+ tmpSyncState.ExistingRecordsInSync = false;
647
934
  }
648
- return fStageComplete();
935
+
936
+ // Now pull records with UpdateDate > local max UpdateDate (new + modified on server)
937
+ const tmpAfterFilter = `FBV~UpdateDate~GT~${tmpDateStr}~FSF~${tmpIDCol}~ASC~ASC`;
938
+
939
+ this._getServerCount(`FBV~UpdateDate~GT~${tmpDateStr}`,
940
+ (pAfterError, pServerCountAfter) =>
941
+ {
942
+ if (pAfterError)
943
+ {
944
+ this.fable.log.warn(`${this.EntitySchema.TableName}: could not get server count after UpdateDate (${pAfterError}).`);
945
+ tmpSyncState.UpdateDateSyncDone = false;
946
+ return fStageComplete();
947
+ }
948
+
949
+ this.fable.log.info(`${this.EntitySchema.TableName}: ${pServerCountAfter} records on server with UpdateDate > ${tmpDateStr}; pulling...`);
950
+
951
+ if (pServerCountAfter < 1)
952
+ {
953
+ tmpSyncState.UpdateDateSyncDone = true;
954
+ return fStageComplete();
955
+ }
956
+
957
+ this._pullServerRecords(tmpAfterFilter, pServerCountAfter,
958
+ (pPullError, pSyncedCount) =>
959
+ {
960
+ if (pPullError)
961
+ {
962
+ this.fable.log.warn(`${this.EntitySchema.TableName}: error pulling new records: ${pPullError}`);
963
+ }
964
+ else
965
+ {
966
+ this.fable.log.info(`${this.EntitySchema.TableName}: pulled ${pSyncedCount} new/modified records via UpdateDate.`);
967
+ }
968
+ tmpSyncState.UpdateDateSyncDone = true;
969
+ return fStageComplete();
970
+ });
971
+ });
649
972
  });
650
973
  },
974
+
975
+ // ---- Stage 4: Bisect existing records if counts did not match ----
651
976
  (fStageComplete) =>
652
977
  {
653
- let tmpTotalRecords = (this.MaxRecordsPerEntity > 0)
654
- ? Math.min(tmpSyncState.Server.RecordCount, this.MaxRecordsPerEntity)
655
- : tmpSyncState.Server.RecordCount;
656
- tmpSyncState.EstimatedRequestCount = Math.ceil(tmpTotalRecords / this.PageSize);
657
- tmpSyncState.RequestsPerformed = 0;
658
- tmpSyncState.LastRequestedID = 0;
978
+ // If UpdateDate sync found existing records in sync, or if we have
979
+ // no local data yet, skip bisection.
980
+ if (tmpSyncState.ExistingRecordsInSync)
981
+ {
982
+ this.fable.log.info(`${this.EntitySchema.TableName}: existing records in sync; skipping bisection.`);
983
+ return fStageComplete();
984
+ }
659
985
 
660
- this.operation.createProgressTracker(tmpSyncState.EstimatedRequestCount, `UpdateSync-${this.EntitySchema.TableName}`);
661
- this.operation.printProgressTrackerStatus(`UpdateSync-${this.EntitySchema.TableName}`);
986
+ // If we have no local records, there is nothing to bisect
987
+ if (tmpSyncState.Local.MaxIDEntity < 1)
988
+ {
989
+ this.fable.log.info(`${this.EntitySchema.TableName}: no local records; skipping bisection.`);
990
+ return fStageComplete();
991
+ }
662
992
 
663
- return fStageComplete();
993
+ // If the UpdateDate fast-sync already ran and pulled new records,
994
+ // refresh local count to see if we are now in sync
995
+ if (tmpSyncState.UpdateDateSyncDone)
996
+ {
997
+ return this._getLocalCount(0, 0,
998
+ (pError, pNewLocalCount) =>
999
+ {
1000
+ if (pError || pNewLocalCount === tmpSyncState.Server.RecordCount)
1001
+ {
1002
+ this.fable.log.info(`${this.EntitySchema.TableName}: counts now match after UpdateDate pull (${pNewLocalCount} local, ${tmpSyncState.Server.RecordCount} server); skipping bisection.`);
1003
+ return fStageComplete();
1004
+ }
1005
+
1006
+ this.fable.log.info(`${this.EntitySchema.TableName}: counts still differ after UpdateDate pull (${pNewLocalCount} local, ${tmpSyncState.Server.RecordCount} server); bisecting existing records...`);
1007
+ return this._bisectRange(1, tmpSyncState.Local.MaxIDEntity, 0, fStageComplete);
1008
+ });
1009
+ }
1010
+
1011
+ // No UpdateDate available -- bisect the full ID range
1012
+ this.fable.log.info(`${this.EntitySchema.TableName}: bisecting full ID range 1-${tmpSyncState.Local.MaxIDEntity}...`);
1013
+ return this._bisectRange(1, tmpSyncState.Local.MaxIDEntity, 0, fStageComplete);
664
1014
  },
1015
+
1016
+ // ---- Stage 5: Pull any remaining new records by ID ----
1017
+ // If no UpdateDate sync ran (table lacks UpdateDate), pull records
1018
+ // with ID > local max ID.
665
1019
  (fStageComplete) =>
666
1020
  {
667
- if (tmpSyncState.EstimatedRequestCount < 1)
1021
+ if (tmpSyncState.UpdateDateSyncDone)
1022
+ {
1023
+ // UpdateDate sync already handled new records
1024
+ return fStageComplete();
1025
+ }
1026
+
1027
+ if (tmpSyncState.Server.MaxIDEntity <= tmpSyncState.Local.MaxIDEntity)
668
1028
  {
669
- this.fable.log.info(`No records to update sync for ${this.EntitySchema.TableName}.`);
1029
+ this.fable.log.info(`${this.EntitySchema.TableName}: no new records by ID (server max ${tmpSyncState.Server.MaxIDEntity} <= local max ${tmpSyncState.Local.MaxIDEntity}).`);
670
1030
  return fStageComplete();
671
1031
  }
672
1032
 
673
- this.addSyncAnticipateEntry(tmpSyncState, tmpAnticipate);
1033
+ const tmpIDCol = this.DefaultIdentifier;
1034
+ const tmpFilter = `FBV~${tmpIDCol}~GT~${tmpSyncState.Local.MaxIDEntity}~FSF~${tmpIDCol}~ASC~ASC`;
1035
+ const tmpEstimated = tmpSyncState.Server.MaxIDEntity - tmpSyncState.Local.MaxIDEntity;
674
1036
 
675
- tmpAnticipate.wait(fStageComplete);
1037
+ this.fable.log.info(`${this.EntitySchema.TableName}: pulling new records with ID > ${tmpSyncState.Local.MaxIDEntity} (~${tmpEstimated} estimated)...`);
1038
+
1039
+ this._pullServerRecords(tmpFilter, tmpEstimated,
1040
+ (pError, pSyncedCount) =>
1041
+ {
1042
+ if (pError)
1043
+ {
1044
+ this.fable.log.warn(`${this.EntitySchema.TableName}: error pulling new records by ID: ${pError}`);
1045
+ }
1046
+ else
1047
+ {
1048
+ this.fable.log.info(`${this.EntitySchema.TableName}: pulled ${pSyncedCount} new records by ID.`);
1049
+ }
1050
+ return fStageComplete();
1051
+ });
676
1052
  },
677
1053
  ],
678
1054
  (pError) =>
679
1055
  {
680
1056
  if (pError)
681
1057
  {
682
- this.fable.log.error(`Error performing Update sync ${this.EntitySchema.TableName}: ${pError}`, { Error: pError });
1058
+ this.fable.log.error(`Error performing ongoing sync ${this.EntitySchema.TableName}: ${pError}`, { Error: pError });
683
1059
  }
684
1060
 
1061
+ this.fable.log.info(`${this.EntitySchema.TableName}: ongoing sync complete.`);
1062
+
685
1063
  if (this.SyncDeletedRecords)
686
1064
  {
687
1065
  return this.syncDeletedRecords(() => { return fCallback(); });
@@ -87,7 +87,7 @@ class MeadowSync extends libFableServiceProviderBase
87
87
  let tmpErrorCount = 0;
88
88
  let tmpSuccessCount = 0;
89
89
 
90
- this.fable.Utility.eachLimit(this.MeadowSchemaTableList, 1,
90
+ this.fable.Utility.eachLimit(this.MeadowSchemaTableList, 5,
91
91
  (pEntitySchemaName, fSyncInitializationComplete) =>
92
92
  {
93
93
  tmpEntityIndex++;