meadow-integration 1.0.21 → 1.0.24

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.
@@ -0,0 +1,1601 @@
1
+ /*
2
+ Unit tests for Ongoing Sync Bisection Algorithm
3
+
4
+ Validates that the bisection-based ongoing sync correctly:
5
+ - Skips unchanged ranges (no record pulls)
6
+ - Detects and syncs changed ranges efficiently
7
+ - Handles count mismatches (missing local records)
8
+ - Scales efficiently with large datasets
9
+
10
+ Uses a filter-aware mock HTTP server to simulate meadow-endpoints API
11
+ responses and an in-memory SQLite database as the local clone destination.
12
+ */
13
+
14
+ const Chai = require('chai');
15
+ const Expect = Chai.expect;
16
+
17
+ const libHTTP = require('http');
18
+ const libFable = require('fable');
19
+ const libMeadow = require('meadow');
20
+ const libMeadowConnectionSQLite = require('meadow-connection-sqlite');
21
+
22
+ const libMeadowCloneRestClient = require('../source/services/clone/Meadow-Service-RestClient.js');
23
+ const libMeadowSync = require('../source/services/clone/Meadow-Service-Sync.js');
24
+ const libMeadowSyncEntityOngoing = require('../source/services/clone/Meadow-Service-Sync-Entity-Ongoing.js');
25
+ const libMeadowSyncEntityInitial = require('../source/services/clone/Meadow-Service-Sync-Entity-Initial.js');
26
+
27
+ // ── Test Constants ──────────────────────────────────────────────────────────────
28
+
29
+ const MOCK_PORT = 18100;
30
+ const MOCK_BASE_URL = `http://localhost:${MOCK_PORT}/1.0/`;
31
+
32
+ const BASE_UPDATE_DATE = '2025-06-15T12:00:00.000Z';
33
+ const NEWER_UPDATE_DATE = '2025-07-01T12:00:00.000Z';
34
+ const NEWEST_UPDATE_DATE = '2025-08-01T12:00:00.000Z';
35
+
36
+ const RECORD_COUNT = 5000;
37
+ const BISECT_MIN_RANGE = 1000;
38
+
39
+ // ── Book Entity Schema (Extended Format) ────────────────────────────────────────
40
+
41
+ const _BookExtendedSchema =
42
+ {
43
+ Tables:
44
+ {
45
+ Book:
46
+ {
47
+ TableName: 'Book',
48
+ Columns:
49
+ [
50
+ { Column: 'IDBook', DataType: 'int' },
51
+ { Column: 'GUIDBook', DataType: 'GUID' },
52
+ { Column: 'CreateDate', DataType: 'DateTime' },
53
+ { Column: 'CreatingIDUser', DataType: 'int' },
54
+ { Column: 'UpdateDate', DataType: 'DateTime' },
55
+ { Column: 'UpdatingIDUser', DataType: 'int' },
56
+ { Column: 'Deleted', DataType: 'int' },
57
+ { Column: 'DeleteDate', DataType: 'DateTime' },
58
+ { Column: 'DeletingIDUser', DataType: 'int' },
59
+ { Column: 'Title', DataType: 'String' },
60
+ { Column: 'Type', DataType: 'String' },
61
+ { Column: 'Genre', DataType: 'String' },
62
+ { Column: 'PublicationYear', DataType: 'int' }
63
+ ],
64
+ MeadowSchema:
65
+ {
66
+ Scope: 'Book',
67
+ DefaultIdentifier: 'IDBook',
68
+ Domain: 'Default',
69
+ Schema:
70
+ [
71
+ { Column: 'IDBook', Type: 'AutoIdentity', Size: 'Default' },
72
+ { Column: 'GUIDBook', Type: 'AutoGUID', Size: '128' },
73
+ { Column: 'CreateDate', Type: 'CreateDate', Size: 'Default' },
74
+ { Column: 'CreatingIDUser', Type: 'CreateIDUser', Size: 'int' },
75
+ { Column: 'UpdateDate', Type: 'UpdateDate', Size: 'Default' },
76
+ { Column: 'UpdatingIDUser', Type: 'UpdateIDUser', Size: 'int' },
77
+ { Column: 'Deleted', Type: 'Deleted', Size: 'Default' },
78
+ { Column: 'DeleteDate', Type: 'DeleteDate', Size: 'Default' },
79
+ { Column: 'DeletingIDUser', Type: 'DeleteIDUser', Size: 'int' },
80
+ { Column: 'Title', Type: 'String', Size: '200' },
81
+ { Column: 'Type', Type: 'String', Size: '32' },
82
+ { Column: 'Genre', Type: 'String', Size: '128' },
83
+ { Column: 'PublicationYear', Type: 'Integer', Size: 'int' }
84
+ ],
85
+ DefaultObject:
86
+ {
87
+ IDBook: 0, GUIDBook: '', CreateDate: null, CreatingIDUser: 0,
88
+ UpdateDate: null, UpdatingIDUser: 0, Deleted: 0,
89
+ DeleteDate: null, DeletingIDUser: 0,
90
+ Title: '', Type: '', Genre: '', PublicationYear: 0
91
+ },
92
+ JsonSchema:
93
+ {
94
+ title: 'Book',
95
+ type: 'object',
96
+ properties:
97
+ {
98
+ IDBook: { type: 'integer' },
99
+ GUIDBook: { type: 'string' },
100
+ CreateDate: { type: 'string' },
101
+ CreatingIDUser: { type: 'integer' },
102
+ UpdateDate: { type: 'string' },
103
+ UpdatingIDUser: { type: 'integer' },
104
+ Deleted: { type: 'boolean' },
105
+ DeleteDate: { type: 'string' },
106
+ DeletingIDUser: { type: 'integer' },
107
+ Title: { type: 'string' },
108
+ Type: { type: 'string' },
109
+ Genre: { type: 'string' },
110
+ PublicationYear: { type: 'integer' }
111
+ },
112
+ required: ['IDBook']
113
+ }
114
+ }
115
+ }
116
+ }
117
+ };
118
+
119
+ // ── Deterministic Data Generator ────────────────────────────────────────────────
120
+
121
+ const GENRES = ['Adventure', 'Mystery', 'Science', 'Romance', 'Technology',
122
+ 'Fantasy', 'History', 'Biography', 'Horror', 'Comedy'];
123
+
124
+ function generateBooks(pCount, pBaseUpdateDate)
125
+ {
126
+ let tmpBooks = [];
127
+ for (let i = 1; i <= pCount; i++)
128
+ {
129
+ tmpBooks.push(
130
+ {
131
+ IDBook: i,
132
+ GUIDBook: `GUID-BOOK-${i}`,
133
+ CreateDate: '2025-01-01T00:00:00.000Z',
134
+ CreatingIDUser: 1,
135
+ UpdateDate: pBaseUpdateDate,
136
+ UpdatingIDUser: 1,
137
+ Deleted: 0,
138
+ DeleteDate: '',
139
+ DeletingIDUser: 0,
140
+ Title: `Book-${i}`,
141
+ Type: 'Fiction',
142
+ Genre: GENRES[i % GENRES.length],
143
+ PublicationYear: 2000 + (i % 26)
144
+ });
145
+ }
146
+ return tmpBooks;
147
+ }
148
+
149
+ function mutateBooks(pBooks, pStartID, pEndID, pNewUpdateDate, pTitlePrefix)
150
+ {
151
+ for (let i = 0; i < pBooks.length; i++)
152
+ {
153
+ if (pBooks[i].IDBook >= pStartID && pBooks[i].IDBook <= pEndID)
154
+ {
155
+ pBooks[i].UpdateDate = pNewUpdateDate;
156
+ if (pTitlePrefix)
157
+ {
158
+ pBooks[i].Title = `${pTitlePrefix}-${pBooks[i].IDBook}`;
159
+ }
160
+ }
161
+ }
162
+ }
163
+
164
+ // ── FBV~ Filter Parser ──────────────────────────────────────────────────────────
165
+ // Parses meadow filter expressions like:
166
+ // FBV~IDBook~GE~100~FBV~IDBook~LE~200~FSF~UpdateDate~DESC~DESC
167
+
168
+ function parseFilter(pFilterString)
169
+ {
170
+ if (!pFilterString) return { filters: [], sort: null };
171
+
172
+ let tmpFilterPart = pFilterString;
173
+ let tmpSort = null;
174
+
175
+ // Split off the FSF sort clause
176
+ let tmpFSFIndex = tmpFilterPart.indexOf('~FSF~');
177
+ if (tmpFSFIndex >= 0)
178
+ {
179
+ let tmpSortPart = tmpFilterPart.substring(tmpFSFIndex + 5); // after ~FSF~
180
+ tmpFilterPart = tmpFilterPart.substring(0, tmpFSFIndex);
181
+ let tmpSortTokens = tmpSortPart.split('~');
182
+ if (tmpSortTokens.length >= 2)
183
+ {
184
+ tmpSort = { Column: tmpSortTokens[0], Direction: tmpSortTokens[1] };
185
+ }
186
+ }
187
+
188
+ // Also handle leading FSF~ (no filter, just sort)
189
+ if (tmpFilterPart.indexOf('FSF~') === 0)
190
+ {
191
+ let tmpSortTokens = tmpFilterPart.substring(4).split('~');
192
+ if (tmpSortTokens.length >= 2)
193
+ {
194
+ tmpSort = { Column: tmpSortTokens[0], Direction: tmpSortTokens[1] };
195
+ }
196
+ return { filters: [], sort: tmpSort };
197
+ }
198
+
199
+ // Parse FBV~ filter clauses
200
+ let tmpFilters = [];
201
+ // Remove leading FBV~ then split on ~FBV~
202
+ if (tmpFilterPart.indexOf('FBV~') === 0)
203
+ {
204
+ tmpFilterPart = tmpFilterPart.substring(4);
205
+ }
206
+ let tmpClauses = tmpFilterPart.split('~FBV~');
207
+ for (let i = 0; i < tmpClauses.length; i++)
208
+ {
209
+ let tmpTokens = tmpClauses[i].split('~');
210
+ if (tmpTokens.length >= 3)
211
+ {
212
+ tmpFilters.push({ Column: tmpTokens[0], Operator: tmpTokens[1], Value: tmpTokens.slice(2).join('~') });
213
+ }
214
+ }
215
+
216
+ return { filters: tmpFilters, sort: tmpSort };
217
+ }
218
+
219
+ function applyFilters(pBooks, pParsed)
220
+ {
221
+ let tmpResult = pBooks;
222
+
223
+ for (let i = 0; i < pParsed.filters.length; i++)
224
+ {
225
+ let tmpFilter = pParsed.filters[i];
226
+ let tmpCol = tmpFilter.Column;
227
+ let tmpOp = tmpFilter.Operator;
228
+ let tmpVal = tmpFilter.Value;
229
+
230
+ tmpResult = tmpResult.filter((pBook) =>
231
+ {
232
+ let tmpBookVal = pBook[tmpCol];
233
+ if (tmpBookVal === undefined || tmpBookVal === null) return false;
234
+
235
+ // Normalize dates: strip trailing Z for comparison
236
+ let tmpCompareBookVal = String(tmpBookVal).replace(/Z$/, '');
237
+ let tmpCompareFilterVal = String(tmpVal).replace(/Z$/, '');
238
+
239
+ // Use numeric comparison for integer columns
240
+ if (tmpCol === 'IDBook' || tmpCol === 'CreatingIDUser' || tmpCol === 'UpdatingIDUser' ||
241
+ tmpCol === 'DeletingIDUser' || tmpCol === 'PublicationYear' || tmpCol === 'Deleted')
242
+ {
243
+ tmpCompareBookVal = Number(tmpBookVal);
244
+ tmpCompareFilterVal = Number(tmpVal);
245
+ }
246
+
247
+ switch (tmpOp)
248
+ {
249
+ case 'GE': return tmpCompareBookVal >= tmpCompareFilterVal;
250
+ case 'LE': return tmpCompareBookVal <= tmpCompareFilterVal;
251
+ case 'GT': return tmpCompareBookVal > tmpCompareFilterVal;
252
+ case 'LT': return tmpCompareBookVal < tmpCompareFilterVal;
253
+ case 'EQ': return tmpCompareBookVal == tmpCompareFilterVal;
254
+ default: return true;
255
+ }
256
+ });
257
+ }
258
+
259
+ // Apply sort
260
+ if (pParsed.sort)
261
+ {
262
+ let tmpSortCol = pParsed.sort.Column;
263
+ let tmpDir = (pParsed.sort.Direction || '').toUpperCase() === 'DESC' ? -1 : 1;
264
+ tmpResult.sort((a, b) =>
265
+ {
266
+ let tmpA = a[tmpSortCol];
267
+ let tmpB = b[tmpSortCol];
268
+ if (tmpA < tmpB) return -1 * tmpDir;
269
+ if (tmpA > tmpB) return 1 * tmpDir;
270
+ return 0;
271
+ });
272
+ }
273
+
274
+ return tmpResult;
275
+ }
276
+
277
+ // ── Mock HTTP Server (Filter-Aware) ─────────────────────────────────────────────
278
+
279
+ let _MockServerData =
280
+ {
281
+ Books: [],
282
+ RequestLog:
283
+ {
284
+ maxIDRequests: 0,
285
+ countRequests: 0,
286
+ countFilteredRequests: 0,
287
+ recordPullRequests: 0,
288
+ totalRecordsPulled: 0
289
+ }
290
+ };
291
+
292
+ function resetRequestLog()
293
+ {
294
+ _MockServerData.RequestLog =
295
+ {
296
+ maxIDRequests: 0,
297
+ countRequests: 0,
298
+ countFilteredRequests: 0,
299
+ recordPullRequests: 0,
300
+ totalRecordsPulled: 0
301
+ };
302
+ }
303
+
304
+ function createMockServer()
305
+ {
306
+ return libHTTP.createServer(
307
+ (pRequest, pResponse) =>
308
+ {
309
+ let tmpURL = pRequest.url.split('?')[0]; // strip query string
310
+ pResponse.setHeader('Content-Type', 'application/json');
311
+
312
+ let tmpBooks = _MockServerData.Books;
313
+
314
+ // GET /1.0/Book/Max/IDBook
315
+ if (tmpURL.match(/\/1\.0\/Book\/Max\/IDBook$/))
316
+ {
317
+ _MockServerData.RequestLog.maxIDRequests++;
318
+ let tmpMaxID = 0;
319
+ for (let i = 0; i < tmpBooks.length; i++)
320
+ {
321
+ if (tmpBooks[i].IDBook > tmpMaxID) tmpMaxID = tmpBooks[i].IDBook;
322
+ }
323
+ pResponse.end(JSON.stringify({ IDBook: tmpMaxID }));
324
+ return;
325
+ }
326
+
327
+ // GET /1.0/Book/Max/UpdateDate
328
+ if (tmpURL.match(/\/1\.0\/Book\/Max\/UpdateDate$/))
329
+ {
330
+ let tmpMaxDate = '';
331
+ for (let i = 0; i < tmpBooks.length; i++)
332
+ {
333
+ if (tmpBooks[i].UpdateDate > tmpMaxDate) tmpMaxDate = tmpBooks[i].UpdateDate;
334
+ }
335
+ pResponse.end(JSON.stringify({ UpdateDate: tmpMaxDate }));
336
+ return;
337
+ }
338
+
339
+ // GET /1.0/Books/Count (unfiltered)
340
+ if (tmpURL.match(/\/1\.0\/Books\/Count$/) && !tmpURL.match(/FilteredTo/))
341
+ {
342
+ _MockServerData.RequestLog.countRequests++;
343
+ pResponse.end(JSON.stringify({ Count: tmpBooks.length }));
344
+ return;
345
+ }
346
+
347
+ // GET /1.0/Books/Count/FilteredTo/<filter>
348
+ let tmpCountFilterMatch = tmpURL.match(/\/1\.0\/Books\/Count\/FilteredTo\/(.+)$/);
349
+ if (tmpCountFilterMatch)
350
+ {
351
+ _MockServerData.RequestLog.countFilteredRequests++;
352
+ let tmpParsed = parseFilter(tmpCountFilterMatch[1]);
353
+ let tmpFiltered = applyFilters(tmpBooks, tmpParsed);
354
+ pResponse.end(JSON.stringify({ Count: tmpFiltered.length }));
355
+ return;
356
+ }
357
+
358
+ // GET /1.0/Books/FilteredTo/<filter>/<offset>/<pageSize>
359
+ let tmpRecordsFilterMatch = tmpURL.match(/\/1\.0\/Books\/FilteredTo\/(.+)\/(\d+)\/(\d+)$/);
360
+ if (tmpRecordsFilterMatch)
361
+ {
362
+ _MockServerData.RequestLog.recordPullRequests++;
363
+ let tmpFilter = tmpRecordsFilterMatch[1];
364
+ let tmpOffset = parseInt(tmpRecordsFilterMatch[2]);
365
+ let tmpPageSize = parseInt(tmpRecordsFilterMatch[3]);
366
+ let tmpParsed = parseFilter(tmpFilter);
367
+ let tmpFiltered = applyFilters(tmpBooks, tmpParsed);
368
+ let tmpPage = tmpFiltered.slice(tmpOffset, tmpOffset + tmpPageSize);
369
+ _MockServerData.RequestLog.totalRecordsPulled += tmpPage.length;
370
+ pResponse.end(JSON.stringify(tmpPage));
371
+ return;
372
+ }
373
+
374
+ // Fallback
375
+ pResponse.statusCode = 404;
376
+ pResponse.end(JSON.stringify({ Error: `Unknown endpoint: ${tmpURL}` }));
377
+ });
378
+ }
379
+
380
+ // ── Test Helpers ────────────────────────────────────────────────────────────────
381
+
382
+ function createTestFable()
383
+ {
384
+ let tmpFable = new libFable(
385
+ {
386
+ Product: 'BisectionSyncTest',
387
+ ProductVersion: '1.0.0',
388
+ MeadowProvider: 'SQLite',
389
+ SQLite: { SQLiteFilePath: ':memory:' },
390
+ LogStreams: [{ streamtype: 'console', level: 'error' }]
391
+ });
392
+
393
+ tmpFable.ProgramConfiguration = {};
394
+
395
+ return tmpFable;
396
+ }
397
+
398
+ function setupSQLiteProvider(pFable, fCallback)
399
+ {
400
+ pFable.serviceManager.addServiceType('MeadowSQLiteProvider', libMeadowConnectionSQLite);
401
+ pFable.serviceManager.instantiateServiceProvider('MeadowSQLiteProvider');
402
+ pFable.MeadowSQLiteProvider.connectAsync(
403
+ (pError) =>
404
+ {
405
+ if (pError) return fCallback(pError);
406
+
407
+ pFable.MeadowSQLiteProvider.db.exec(`
408
+ CREATE TABLE IF NOT EXISTS Book (
409
+ IDBook INTEGER PRIMARY KEY AUTOINCREMENT,
410
+ GUIDBook TEXT DEFAULT '',
411
+ CreateDate TEXT DEFAULT '',
412
+ CreatingIDUser INTEGER DEFAULT 0,
413
+ UpdateDate TEXT DEFAULT '',
414
+ UpdatingIDUser INTEGER DEFAULT 0,
415
+ Deleted INTEGER DEFAULT 0,
416
+ DeleteDate TEXT DEFAULT '',
417
+ DeletingIDUser INTEGER DEFAULT 0,
418
+ Title TEXT DEFAULT '',
419
+ Type TEXT DEFAULT '',
420
+ Genre TEXT DEFAULT '',
421
+ PublicationYear INTEGER DEFAULT 0
422
+ );
423
+ `);
424
+
425
+ return fCallback();
426
+ });
427
+ }
428
+
429
+ function seedLocalBooks(pFable, pBooks)
430
+ {
431
+ const tmpInsert = pFable.MeadowSQLiteProvider.db.prepare(`
432
+ INSERT OR REPLACE INTO Book (IDBook, GUIDBook, CreateDate, CreatingIDUser, UpdateDate, UpdatingIDUser, Deleted, DeleteDate, DeletingIDUser, Title, Type, Genre, PublicationYear)
433
+ VALUES (@IDBook, @GUIDBook, @CreateDate, @CreatingIDUser, @UpdateDate, @UpdatingIDUser, @Deleted, @DeleteDate, @DeletingIDUser, @Title, @Type, @Genre, @PublicationYear)
434
+ `);
435
+ const tmpInsertMany = pFable.MeadowSQLiteProvider.db.transaction((pRecords) =>
436
+ {
437
+ for (const tmpRecord of pRecords)
438
+ {
439
+ tmpInsert.run(tmpRecord);
440
+ }
441
+ });
442
+ tmpInsertMany(pBooks);
443
+ }
444
+
445
+ function getLocalBookCount(pFable)
446
+ {
447
+ return pFable.MeadowSQLiteProvider.db
448
+ .prepare('SELECT COUNT(*) AS cnt FROM Book WHERE Deleted = 0')
449
+ .get().cnt;
450
+ }
451
+
452
+ function getLocalBook(pFable, pID)
453
+ {
454
+ return pFable.MeadowSQLiteProvider.db
455
+ .prepare('SELECT * FROM Book WHERE IDBook = ?')
456
+ .get(pID);
457
+ }
458
+
459
+ function setupSyncServices(pFable, pSyncMode, fCallback, pExtraOptions)
460
+ {
461
+ if (!pFable.serviceManager.servicesMap.hasOwnProperty('MeadowCloneRestClient'))
462
+ {
463
+ pFable.serviceManager.addServiceType('MeadowCloneRestClient', libMeadowCloneRestClient);
464
+ }
465
+ pFable.serviceManager.instantiateServiceProvider('MeadowCloneRestClient',
466
+ {
467
+ ServerURL: MOCK_BASE_URL
468
+ });
469
+
470
+ if (!pFable.serviceManager.servicesMap.hasOwnProperty('MeadowSync'))
471
+ {
472
+ pFable.serviceManager.addServiceType('MeadowSync', libMeadowSync);
473
+ }
474
+ if (!pFable.serviceManager.servicesMap.hasOwnProperty('MeadowSyncEntityInitial'))
475
+ {
476
+ pFable.serviceManager.addServiceType('MeadowSyncEntityInitial', libMeadowSyncEntityInitial);
477
+ }
478
+ if (!pFable.serviceManager.servicesMap.hasOwnProperty('MeadowSyncEntityOngoing'))
479
+ {
480
+ pFable.serviceManager.addServiceType('MeadowSyncEntityOngoing', libMeadowSyncEntityOngoing);
481
+ }
482
+
483
+ let tmpSyncOptions =
484
+ {
485
+ PageSize: 100,
486
+ BisectMinRangeSize: BISECT_MIN_RANGE
487
+ };
488
+
489
+ if (pExtraOptions)
490
+ {
491
+ Object.assign(tmpSyncOptions, pExtraOptions);
492
+ }
493
+
494
+ pFable.serviceManager.instantiateServiceProvider('MeadowSync', tmpSyncOptions);
495
+
496
+ pFable.MeadowSync.SyncMode = pSyncMode;
497
+
498
+ pFable.MeadowSync.loadMeadowSchema(_BookExtendedSchema,
499
+ (pError) =>
500
+ {
501
+ return fCallback(pError);
502
+ });
503
+ }
504
+
505
+ // ── Test Suite ──────────────────────────────────────────────────────────────────
506
+
507
+ suite
508
+ (
509
+ 'Bisection Sync',
510
+ () =>
511
+ {
512
+ let _MockServer = null;
513
+
514
+ suiteSetup
515
+ (
516
+ function (fDone)
517
+ {
518
+ this.timeout(10000);
519
+ _MockServer = createMockServer();
520
+ _MockServer.listen(MOCK_PORT, () => { return fDone(); });
521
+ }
522
+ );
523
+
524
+ suiteTeardown
525
+ (
526
+ (fDone) =>
527
+ {
528
+ if (_MockServer)
529
+ {
530
+ _MockServer.close(fDone);
531
+ }
532
+ else
533
+ {
534
+ return fDone();
535
+ }
536
+ }
537
+ );
538
+
539
+ // ── Initial Sync Baseline ───────────────────────────────────────────
540
+
541
+ suite
542
+ (
543
+ 'Initial Sync Baseline',
544
+ () =>
545
+ {
546
+ test
547
+ (
548
+ `Should sync ${RECORD_COUNT} records via Initial mode`,
549
+ function (fDone)
550
+ {
551
+ this.timeout(120000);
552
+
553
+ _MockServerData.Books = generateBooks(RECORD_COUNT, BASE_UPDATE_DATE);
554
+
555
+ let tmpFable = createTestFable();
556
+ setupSQLiteProvider(tmpFable,
557
+ (pError) =>
558
+ {
559
+ Expect(pError).to.not.exist;
560
+ setupSyncServices(tmpFable, 'Initial',
561
+ (pError) =>
562
+ {
563
+ Expect(pError).to.not.exist;
564
+ tmpFable.MeadowSync.syncAll(
565
+ (pSyncError) =>
566
+ {
567
+ Expect(pSyncError).to.not.exist;
568
+ let tmpCount = getLocalBookCount(tmpFable);
569
+ Expect(tmpCount).to.equal(RECORD_COUNT);
570
+ return fDone();
571
+ });
572
+ });
573
+ });
574
+ }
575
+ );
576
+ }
577
+ );
578
+
579
+ // ── Ongoing Sync — No Changes ───────────────────────────────────────
580
+
581
+ suite
582
+ (
583
+ 'Ongoing Sync - No Changes',
584
+ () =>
585
+ {
586
+ test
587
+ (
588
+ 'Should pull zero records when server and local are identical',
589
+ function (fDone)
590
+ {
591
+ this.timeout(120000);
592
+
593
+ let tmpBooks = generateBooks(RECORD_COUNT, BASE_UPDATE_DATE);
594
+ _MockServerData.Books = tmpBooks.map((b) => Object.assign({}, b));
595
+
596
+ let tmpFable = createTestFable();
597
+ setupSQLiteProvider(tmpFable,
598
+ (pError) =>
599
+ {
600
+ Expect(pError).to.not.exist;
601
+
602
+ // Seed local DB directly with the same data
603
+ seedLocalBooks(tmpFable, tmpBooks);
604
+ Expect(getLocalBookCount(tmpFable)).to.equal(RECORD_COUNT);
605
+
606
+ resetRequestLog();
607
+
608
+ setupSyncServices(tmpFable, 'Ongoing',
609
+ (pError) =>
610
+ {
611
+ Expect(pError).to.not.exist;
612
+ tmpFable.MeadowSync.syncAll(
613
+ (pSyncError) =>
614
+ {
615
+ Expect(pSyncError).to.not.exist;
616
+
617
+ let tmpEntity = tmpFable.MeadowSync.MeadowSyncEntities['Book'];
618
+ Expect(tmpEntity.syncResults.Created).to.equal(0);
619
+ Expect(tmpEntity.syncResults.Updated).to.equal(0);
620
+
621
+ // Key assertion: bisection should NOT pull records
622
+ Expect(_MockServerData.RequestLog.totalRecordsPulled).to.equal(0,
623
+ 'No records should be pulled when data is identical');
624
+
625
+ return fDone();
626
+ });
627
+ });
628
+ });
629
+ }
630
+ );
631
+ }
632
+ );
633
+
634
+ // ── Ongoing Sync — Targeted Updates (UpdateDate Fast-Sync) ───────────
635
+
636
+ suite
637
+ (
638
+ 'Ongoing Sync - Targeted Updates via UpdateDate',
639
+ () =>
640
+ {
641
+ test
642
+ (
643
+ 'Should pull only modified records when 50 records are updated',
644
+ function (fDone)
645
+ {
646
+ this.timeout(120000);
647
+
648
+ let tmpBooks = generateBooks(RECORD_COUNT, BASE_UPDATE_DATE);
649
+ _MockServerData.Books = tmpBooks.map((b) => Object.assign({}, b));
650
+
651
+ let tmpFable = createTestFable();
652
+ setupSQLiteProvider(tmpFable,
653
+ (pError) =>
654
+ {
655
+ Expect(pError).to.not.exist;
656
+
657
+ // Seed local with original data
658
+ seedLocalBooks(tmpFable, tmpBooks);
659
+
660
+ // Now mutate 50 records on the server (IDs 2001-2050)
661
+ mutateBooks(_MockServerData.Books, 2001, 2050, NEWER_UPDATE_DATE, 'Updated');
662
+
663
+ resetRequestLog();
664
+
665
+ setupSyncServices(tmpFable, 'Ongoing',
666
+ (pError) =>
667
+ {
668
+ Expect(pError).to.not.exist;
669
+ tmpFable.MeadowSync.syncAll(
670
+ (pSyncError) =>
671
+ {
672
+ Expect(pSyncError).to.not.exist;
673
+
674
+ let tmpEntity = tmpFable.MeadowSync.MeadowSyncEntities['Book'];
675
+ Expect(tmpEntity.syncResults.Updated).to.equal(50);
676
+ Expect(tmpEntity.syncResults.Created).to.equal(0);
677
+
678
+ // Verify the updated records are correct locally
679
+ let tmpLocal2025 = getLocalBook(tmpFable, 2025);
680
+ Expect(tmpLocal2025.Title).to.equal('Updated-2025');
681
+
682
+ // An unchanged record should NOT have been touched
683
+ let tmpLocal1000 = getLocalBook(tmpFable, 1000);
684
+ Expect(tmpLocal1000.Title).to.equal('Book-1000');
685
+
686
+ // Efficiency: should pull only ~50 records, not all 5000
687
+ Expect(_MockServerData.RequestLog.totalRecordsPulled).to.be.below(200,
688
+ 'Should pull far fewer records than the full dataset');
689
+
690
+ return fDone();
691
+ });
692
+ });
693
+ });
694
+ }
695
+ );
696
+ }
697
+ );
698
+
699
+ // ── Ongoing Sync — New Records Appended ─────────────────────────────
700
+
701
+ suite
702
+ (
703
+ 'Ongoing Sync - New Records Appended',
704
+ () =>
705
+ {
706
+ test
707
+ (
708
+ 'Should pull only new records when 200 records are added at the end',
709
+ function (fDone)
710
+ {
711
+ this.timeout(120000);
712
+
713
+ let tmpLocalBooks = generateBooks(RECORD_COUNT, BASE_UPDATE_DATE);
714
+ let tmpServerBooks = generateBooks(RECORD_COUNT + 200, BASE_UPDATE_DATE);
715
+ // Give the new records a newer UpdateDate
716
+ mutateBooks(tmpServerBooks, RECORD_COUNT + 1, RECORD_COUNT + 200, NEWER_UPDATE_DATE);
717
+
718
+ _MockServerData.Books = tmpServerBooks;
719
+
720
+ let tmpFable = createTestFable();
721
+ setupSQLiteProvider(tmpFable,
722
+ (pError) =>
723
+ {
724
+ Expect(pError).to.not.exist;
725
+ seedLocalBooks(tmpFable, tmpLocalBooks);
726
+
727
+ resetRequestLog();
728
+
729
+ setupSyncServices(tmpFable, 'Ongoing',
730
+ (pError) =>
731
+ {
732
+ Expect(pError).to.not.exist;
733
+ tmpFable.MeadowSync.syncAll(
734
+ (pSyncError) =>
735
+ {
736
+ Expect(pSyncError).to.not.exist;
737
+
738
+ let tmpEntity = tmpFable.MeadowSync.MeadowSyncEntities['Book'];
739
+ Expect(tmpEntity.syncResults.Created).to.equal(200);
740
+
741
+ let tmpFinalCount = getLocalBookCount(tmpFable);
742
+ Expect(tmpFinalCount).to.equal(RECORD_COUNT + 200);
743
+
744
+ // Efficiency: should pull ~200 new records, not re-pull existing
745
+ Expect(_MockServerData.RequestLog.totalRecordsPulled).to.be.below(400,
746
+ 'Should pull only the new records plus minimal overhead');
747
+
748
+ return fDone();
749
+ });
750
+ });
751
+ });
752
+ }
753
+ );
754
+ }
755
+ );
756
+
757
+ // ── Ongoing Sync — Small Recent Changes ─────────────────────────────
758
+
759
+ suite
760
+ (
761
+ 'Ongoing Sync - Small Recent Changes (tail of dataset)',
762
+ () =>
763
+ {
764
+ test
765
+ (
766
+ 'Should efficiently handle 10 recently-updated records near the end',
767
+ function (fDone)
768
+ {
769
+ this.timeout(120000);
770
+
771
+ let tmpBooks = generateBooks(RECORD_COUNT, BASE_UPDATE_DATE);
772
+ _MockServerData.Books = tmpBooks.map((b) => Object.assign({}, b));
773
+
774
+ let tmpFable = createTestFable();
775
+ setupSQLiteProvider(tmpFable,
776
+ (pError) =>
777
+ {
778
+ Expect(pError).to.not.exist;
779
+ seedLocalBooks(tmpFable, tmpBooks);
780
+
781
+ // Mutate just 10 records near the end (IDs 4990-4999)
782
+ mutateBooks(_MockServerData.Books, 4990, 4999, NEWER_UPDATE_DATE, 'Recent');
783
+
784
+ resetRequestLog();
785
+
786
+ setupSyncServices(tmpFable, 'Ongoing',
787
+ (pError) =>
788
+ {
789
+ Expect(pError).to.not.exist;
790
+ tmpFable.MeadowSync.syncAll(
791
+ (pSyncError) =>
792
+ {
793
+ Expect(pSyncError).to.not.exist;
794
+
795
+ let tmpEntity = tmpFable.MeadowSync.MeadowSyncEntities['Book'];
796
+ Expect(tmpEntity.syncResults.Updated).to.equal(10);
797
+ Expect(tmpEntity.syncResults.Created).to.equal(0);
798
+
799
+ // Verify correct records updated
800
+ Expect(getLocalBook(tmpFable, 4995).Title).to.equal('Recent-4995');
801
+ Expect(getLocalBook(tmpFable, 4989).Title).to.equal('Book-4989');
802
+
803
+ // Efficiency: should pull very few records
804
+ Expect(_MockServerData.RequestLog.totalRecordsPulled).to.be.below(100,
805
+ 'Small tail changes should require very few record pulls');
806
+
807
+ return fDone();
808
+ });
809
+ });
810
+ });
811
+ }
812
+ );
813
+ }
814
+ );
815
+
816
+ // ── Ongoing Sync — Large Changeset ──────────────────────────────────
817
+
818
+ suite
819
+ (
820
+ 'Ongoing Sync - Large Changeset (half the dataset)',
821
+ () =>
822
+ {
823
+ test
824
+ (
825
+ 'Should handle updating 2500 of 5000 records',
826
+ function (fDone)
827
+ {
828
+ this.timeout(120000);
829
+
830
+ let tmpBooks = generateBooks(RECORD_COUNT, BASE_UPDATE_DATE);
831
+ _MockServerData.Books = tmpBooks.map((b) => Object.assign({}, b));
832
+
833
+ let tmpFable = createTestFable();
834
+ setupSQLiteProvider(tmpFable,
835
+ (pError) =>
836
+ {
837
+ Expect(pError).to.not.exist;
838
+ seedLocalBooks(tmpFable, tmpBooks);
839
+
840
+ // Update the entire second half of the dataset
841
+ mutateBooks(_MockServerData.Books, 2501, 5000, NEWER_UPDATE_DATE, 'Bulk');
842
+
843
+ resetRequestLog();
844
+
845
+ setupSyncServices(tmpFable, 'Ongoing',
846
+ (pError) =>
847
+ {
848
+ Expect(pError).to.not.exist;
849
+ tmpFable.MeadowSync.syncAll(
850
+ (pSyncError) =>
851
+ {
852
+ Expect(pSyncError).to.not.exist;
853
+
854
+ let tmpEntity = tmpFable.MeadowSync.MeadowSyncEntities['Book'];
855
+ Expect(tmpEntity.syncResults.Updated).to.equal(2500);
856
+ Expect(tmpEntity.syncResults.Created).to.equal(0);
857
+
858
+ // Verify boundary records
859
+ Expect(getLocalBook(tmpFable, 2500).Title).to.equal('Book-2500');
860
+ Expect(getLocalBook(tmpFable, 2501).Title).to.equal('Bulk-2501');
861
+ Expect(getLocalBook(tmpFable, 5000).Title).to.equal('Bulk-5000');
862
+
863
+ // Even with 2500 changes, should still be more efficient
864
+ // than pulling all 5000 (the UpdateDate fast-sync
865
+ // handles this without bisection)
866
+ Expect(_MockServerData.RequestLog.totalRecordsPulled).to.be.below(3000,
867
+ 'Large changeset should not require pulling the entire dataset');
868
+
869
+ return fDone();
870
+ });
871
+ });
872
+ });
873
+ }
874
+ );
875
+ }
876
+ );
877
+
878
+ // ── Ongoing Sync — Scattered Small Changes ──────────────────────────
879
+
880
+ suite
881
+ (
882
+ 'Ongoing Sync - Scattered Small Changes',
883
+ () =>
884
+ {
885
+ test
886
+ (
887
+ 'Should handle 5 records changed at different positions',
888
+ function (fDone)
889
+ {
890
+ this.timeout(120000);
891
+
892
+ let tmpBooks = generateBooks(RECORD_COUNT, BASE_UPDATE_DATE);
893
+ _MockServerData.Books = tmpBooks.map((b) => Object.assign({}, b));
894
+
895
+ let tmpFable = createTestFable();
896
+ setupSQLiteProvider(tmpFable,
897
+ (pError) =>
898
+ {
899
+ Expect(pError).to.not.exist;
900
+ seedLocalBooks(tmpFable, tmpBooks);
901
+
902
+ // Scatter changes across the dataset
903
+ _MockServerData.Books[9].UpdateDate = NEWER_UPDATE_DATE; // ID 10
904
+ _MockServerData.Books[9].Title = 'Scattered-10';
905
+ _MockServerData.Books[999].UpdateDate = NEWER_UPDATE_DATE; // ID 1000
906
+ _MockServerData.Books[999].Title = 'Scattered-1000';
907
+ _MockServerData.Books[2499].UpdateDate = NEWER_UPDATE_DATE; // ID 2500
908
+ _MockServerData.Books[2499].Title = 'Scattered-2500';
909
+ _MockServerData.Books[3999].UpdateDate = NEWER_UPDATE_DATE; // ID 4000
910
+ _MockServerData.Books[3999].Title = 'Scattered-4000';
911
+ _MockServerData.Books[4999].UpdateDate = NEWER_UPDATE_DATE; // ID 5000
912
+ _MockServerData.Books[4999].Title = 'Scattered-5000';
913
+
914
+ resetRequestLog();
915
+
916
+ setupSyncServices(tmpFable, 'Ongoing',
917
+ (pError) =>
918
+ {
919
+ Expect(pError).to.not.exist;
920
+ tmpFable.MeadowSync.syncAll(
921
+ (pSyncError) =>
922
+ {
923
+ Expect(pSyncError).to.not.exist;
924
+
925
+ let tmpEntity = tmpFable.MeadowSync.MeadowSyncEntities['Book'];
926
+ Expect(tmpEntity.syncResults.Updated).to.equal(5);
927
+ Expect(tmpEntity.syncResults.Created).to.equal(0);
928
+
929
+ // Verify all scattered changes applied
930
+ Expect(getLocalBook(tmpFable, 10).Title).to.equal('Scattered-10');
931
+ Expect(getLocalBook(tmpFable, 1000).Title).to.equal('Scattered-1000');
932
+ Expect(getLocalBook(tmpFable, 2500).Title).to.equal('Scattered-2500');
933
+ Expect(getLocalBook(tmpFable, 4000).Title).to.equal('Scattered-4000');
934
+ Expect(getLocalBook(tmpFable, 5000).Title).to.equal('Scattered-5000');
935
+
936
+ return fDone();
937
+ });
938
+ });
939
+ });
940
+ }
941
+ );
942
+ }
943
+ );
944
+
945
+ // ── Direct Bisection — Unchanged Data ───────────────────────────────
946
+
947
+ suite
948
+ (
949
+ 'Direct Bisection - Unchanged Data',
950
+ () =>
951
+ {
952
+ test
953
+ (
954
+ 'Should skip all ranges when data is identical (zero record pulls)',
955
+ function (fDone)
956
+ {
957
+ this.timeout(120000);
958
+
959
+ let tmpBooks = generateBooks(RECORD_COUNT, BASE_UPDATE_DATE);
960
+ _MockServerData.Books = tmpBooks.map((b) => Object.assign({}, b));
961
+
962
+ let tmpFable = createTestFable();
963
+ setupSQLiteProvider(tmpFable,
964
+ (pError) =>
965
+ {
966
+ Expect(pError).to.not.exist;
967
+ seedLocalBooks(tmpFable, tmpBooks);
968
+
969
+ resetRequestLog();
970
+
971
+ setupSyncServices(tmpFable, 'Ongoing',
972
+ (pError) =>
973
+ {
974
+ Expect(pError).to.not.exist;
975
+
976
+ let tmpEntity = tmpFable.MeadowSync.MeadowSyncEntities['Book'];
977
+
978
+ // Initialize internal state that _syncInternal normally sets
979
+ tmpEntity._recordsCreated = 0;
980
+ tmpEntity._recordsUpdated = 0;
981
+ tmpEntity._totalSyncedThisSync = 0;
982
+ tmpEntity._hasUpdateDate = true;
983
+ tmpEntity._hasDeletedColumn = true;
984
+ tmpEntity.operation.createProgressTracker(RECORD_COUNT, 'FullSync-Book');
985
+
986
+ // Call _bisectRange directly
987
+ tmpEntity._bisectRange(1, RECORD_COUNT, 0,
988
+ () =>
989
+ {
990
+ // The bisection checks max UpdateDate by requesting 1 record
991
+ // (sorted DESC, limit 1) which counts as a record pull request.
992
+ // But no actual range pulls should occur.
993
+ Expect(_MockServerData.RequestLog.totalRecordsPulled).to.be.at.most(1,
994
+ 'Bisection should pull at most 1 record (date-check metadata) when data is identical');
995
+
996
+ // Should have minimal count queries (ideally just 1 at top level
997
+ // if counts+dates match immediately)
998
+ Expect(_MockServerData.RequestLog.countFilteredRequests).to.be.at.most(2,
999
+ 'Should require very few count queries when data matches at top level');
1000
+
1001
+ return fDone();
1002
+ });
1003
+ });
1004
+ });
1005
+ }
1006
+ );
1007
+ }
1008
+ );
1009
+
1010
+ // ── Direct Bisection — Changed Range ────────────────────────────────
1011
+
1012
+ suite
1013
+ (
1014
+ 'Direct Bisection - Changed Range',
1015
+ () =>
1016
+ {
1017
+ test
1018
+ (
1019
+ 'Should only pull records from the range containing changes',
1020
+ function (fDone)
1021
+ {
1022
+ this.timeout(120000);
1023
+
1024
+ let tmpBooks = generateBooks(RECORD_COUNT, BASE_UPDATE_DATE);
1025
+ _MockServerData.Books = tmpBooks.map((b) => Object.assign({}, b));
1026
+
1027
+ let tmpFable = createTestFable();
1028
+ setupSQLiteProvider(tmpFable,
1029
+ (pError) =>
1030
+ {
1031
+ Expect(pError).to.not.exist;
1032
+ seedLocalBooks(tmpFable, tmpBooks);
1033
+
1034
+ // Mutate 50 records in the server (IDs 2001-2050)
1035
+ mutateBooks(_MockServerData.Books, 2001, 2050, NEWER_UPDATE_DATE, 'Bisect-Changed');
1036
+
1037
+ resetRequestLog();
1038
+
1039
+ setupSyncServices(tmpFable, 'Ongoing',
1040
+ (pError) =>
1041
+ {
1042
+ Expect(pError).to.not.exist;
1043
+
1044
+ let tmpEntity = tmpFable.MeadowSync.MeadowSyncEntities['Book'];
1045
+
1046
+ // Initialize internal state that _syncInternal normally sets
1047
+ tmpEntity._recordsCreated = 0;
1048
+ tmpEntity._recordsUpdated = 0;
1049
+ tmpEntity._totalSyncedThisSync = 0;
1050
+ tmpEntity._hasUpdateDate = true;
1051
+ tmpEntity._hasDeletedColumn = true;
1052
+ tmpEntity.operation.createProgressTracker(RECORD_COUNT, 'FullSync-Book');
1053
+
1054
+ tmpEntity._bisectRange(1, RECORD_COUNT, 0,
1055
+ () =>
1056
+ {
1057
+ // Bisection operates at the range level: it pulls the entire
1058
+ // leaf range containing the changed records, then upserts all
1059
+ // records in that range (unconditional update for existing).
1060
+ // With BisectMinRangeSize=1000 and 5000 records, the affected
1061
+ // leaf range is ~625 records.
1062
+ Expect(tmpEntity._recordsUpdated).to.be.at.least(50,
1063
+ 'Should update at least the 50 changed records');
1064
+ Expect(tmpEntity._recordsUpdated).to.be.below(1500,
1065
+ 'Should not update the entire dataset');
1066
+
1067
+ // Efficiency: only the affected leaf range(s) should be pulled
1068
+ Expect(_MockServerData.RequestLog.totalRecordsPulled).to.be.below(1500,
1069
+ 'Should pull only the affected range, not the entire dataset');
1070
+ Expect(_MockServerData.RequestLog.totalRecordsPulled).to.be.above(49,
1071
+ 'Must pull at least the 50 changed records');
1072
+
1073
+ // Verify the changed records
1074
+ Expect(getLocalBook(tmpFable, 2025).Title).to.equal('Bisect-Changed-2025');
1075
+ // Verify an unchanged record was NOT re-fetched
1076
+ Expect(getLocalBook(tmpFable, 1000).Title).to.equal('Book-1000');
1077
+
1078
+ return fDone();
1079
+ });
1080
+ });
1081
+ });
1082
+ }
1083
+ );
1084
+ }
1085
+ );
1086
+
1087
+ // ── Direct Bisection — Count Mismatch (Missing Local Records) ───────
1088
+
1089
+ suite
1090
+ (
1091
+ 'Direct Bisection - Count Mismatch',
1092
+ () =>
1093
+ {
1094
+ test
1095
+ (
1096
+ 'Should pull missing records when local is missing a contiguous range',
1097
+ function (fDone)
1098
+ {
1099
+ this.timeout(120000);
1100
+
1101
+ let tmpBooks = generateBooks(RECORD_COUNT, BASE_UPDATE_DATE);
1102
+ _MockServerData.Books = tmpBooks.map((b) => Object.assign({}, b));
1103
+
1104
+ let tmpFable = createTestFable();
1105
+ setupSQLiteProvider(tmpFable,
1106
+ (pError) =>
1107
+ {
1108
+ Expect(pError).to.not.exist;
1109
+
1110
+ // Seed local with all EXCEPT IDs 3001-3050 (50 missing records)
1111
+ let tmpLocalBooks = tmpBooks.filter(
1112
+ (b) => b.IDBook < 3001 || b.IDBook > 3050);
1113
+ seedLocalBooks(tmpFable, tmpLocalBooks);
1114
+ Expect(getLocalBookCount(tmpFable)).to.equal(RECORD_COUNT - 50);
1115
+
1116
+ resetRequestLog();
1117
+
1118
+ setupSyncServices(tmpFable, 'Ongoing',
1119
+ (pError) =>
1120
+ {
1121
+ Expect(pError).to.not.exist;
1122
+
1123
+ let tmpEntity = tmpFable.MeadowSync.MeadowSyncEntities['Book'];
1124
+
1125
+ // Initialize internal state that _syncInternal normally sets
1126
+ tmpEntity._recordsCreated = 0;
1127
+ tmpEntity._recordsUpdated = 0;
1128
+ tmpEntity._totalSyncedThisSync = 0;
1129
+ tmpEntity._hasUpdateDate = true;
1130
+ tmpEntity._hasDeletedColumn = true;
1131
+ tmpEntity.operation.createProgressTracker(RECORD_COUNT, 'FullSync-Book');
1132
+
1133
+ tmpEntity._bisectRange(1, RECORD_COUNT, 0,
1134
+ () =>
1135
+ {
1136
+ Expect(tmpEntity._recordsCreated).to.equal(50,
1137
+ 'Should create exactly 50 missing records');
1138
+
1139
+ // The 50 missing records should now exist locally
1140
+ Expect(getLocalBookCount(tmpFable)).to.equal(RECORD_COUNT);
1141
+ Expect(getLocalBook(tmpFable, 3025)).to.not.be.undefined;
1142
+ Expect(getLocalBook(tmpFable, 3025).Title).to.equal('Book-3025');
1143
+
1144
+ // Efficiency: should not pull the entire dataset
1145
+ Expect(_MockServerData.RequestLog.totalRecordsPulled).to.be.below(1500,
1146
+ 'Should only pull the range containing the missing records');
1147
+
1148
+ return fDone();
1149
+ });
1150
+ });
1151
+ });
1152
+ }
1153
+ );
1154
+ }
1155
+ );
1156
+
1157
+ // ── Idempotency ─────────────────────────────────────────────────────
1158
+
1159
+ suite
1160
+ (
1161
+ 'Idempotency',
1162
+ () =>
1163
+ {
1164
+ test
1165
+ (
1166
+ 'Should pull zero records on a second ongoing sync after changes are applied',
1167
+ function (fDone)
1168
+ {
1169
+ this.timeout(120000);
1170
+
1171
+ let tmpBooks = generateBooks(RECORD_COUNT, BASE_UPDATE_DATE);
1172
+ _MockServerData.Books = tmpBooks.map((b) => Object.assign({}, b));
1173
+
1174
+ let tmpFable = createTestFable();
1175
+ setupSQLiteProvider(tmpFable,
1176
+ (pError) =>
1177
+ {
1178
+ Expect(pError).to.not.exist;
1179
+ seedLocalBooks(tmpFable, tmpBooks);
1180
+
1181
+ // Mutate 50 records on server
1182
+ mutateBooks(_MockServerData.Books, 2001, 2050, NEWER_UPDATE_DATE, 'Idempotent');
1183
+
1184
+ // First ongoing sync — should pull the changes
1185
+ setupSyncServices(tmpFable, 'Ongoing',
1186
+ (pError) =>
1187
+ {
1188
+ Expect(pError).to.not.exist;
1189
+ tmpFable.MeadowSync.syncAll(
1190
+ (pSyncError) =>
1191
+ {
1192
+ Expect(pSyncError).to.not.exist;
1193
+
1194
+ let tmpEntity1 = tmpFable.MeadowSync.MeadowSyncEntities['Book'];
1195
+ Expect(tmpEntity1.syncResults.Updated).to.equal(50);
1196
+
1197
+ // Verify the sync actually applied changes locally
1198
+ Expect(getLocalBook(tmpFable, 2025).Title).to.equal('Idempotent-2025');
1199
+
1200
+ // Diagnostic: check what the local UpdateDate looks like
1201
+ // vs what the server has -- a mismatch here is the root cause
1202
+ // of the "walks all records" bug
1203
+ let tmpLocalDate = getLocalBook(tmpFable, 2025).UpdateDate;
1204
+ let tmpServerDate = _MockServerData.Books[2024].UpdateDate;
1205
+ // marshalRecord formats dates with space separator
1206
+ // (YYYY-MM-DD HH:mm:ss.SSS) while server uses ISO T separator.
1207
+ // Normalize both for comparison.
1208
+ let tmpNormLocal = tmpLocalDate.replace(/Z$/, '').replace('T', ' ');
1209
+ let tmpNormServer = tmpServerDate.replace(/Z$/, '').replace('T', ' ');
1210
+ Expect(tmpNormLocal).to.equal(tmpNormServer,
1211
+ 'Local UpdateDate should match server UpdateDate after sync');
1212
+
1213
+ // Second ongoing sync — should find nothing to do
1214
+ resetRequestLog();
1215
+
1216
+ // Re-instantiate MeadowSync on the same Fable to get
1217
+ // a fresh sync entity but reuse the same DB connection
1218
+ setupSyncServices(tmpFable, 'Ongoing',
1219
+ (pError2) =>
1220
+ {
1221
+ Expect(pError2).to.not.exist;
1222
+ tmpFable.MeadowSync.syncAll(
1223
+ (pSyncError2) =>
1224
+ {
1225
+ Expect(pSyncError2).to.not.exist;
1226
+
1227
+ let tmpEntity2 = tmpFable.MeadowSync.MeadowSyncEntities['Book'];
1228
+ Expect(tmpEntity2.syncResults.Created).to.equal(0,
1229
+ 'Second sync should create zero records');
1230
+ Expect(tmpEntity2.syncResults.Updated).to.equal(0,
1231
+ 'Second sync should update zero records');
1232
+ Expect(_MockServerData.RequestLog.totalRecordsPulled).to.equal(0,
1233
+ 'Second sync should pull zero records');
1234
+
1235
+ return fDone();
1236
+ });
1237
+ });
1238
+ });
1239
+ });
1240
+ });
1241
+ }
1242
+ );
1243
+ }
1244
+ );
1245
+
1246
+ // ── Deleted Records — Late Enable ───────────────────────────────────
1247
+
1248
+ suite
1249
+ (
1250
+ 'Deleted Records - Late Enable of SyncDeletedRecords',
1251
+ () =>
1252
+ {
1253
+ test
1254
+ (
1255
+ 'Should create-as-deleted records that were never synced locally',
1256
+ function (fDone)
1257
+ {
1258
+ this.timeout(120000);
1259
+
1260
+ // Server has 5000 active + 100 deleted records
1261
+ let tmpActiveBooks = generateBooks(RECORD_COUNT, BASE_UPDATE_DATE);
1262
+ let tmpDeletedBooks = [];
1263
+ for (let i = RECORD_COUNT + 1; i <= RECORD_COUNT + 100; i++)
1264
+ {
1265
+ tmpDeletedBooks.push(
1266
+ {
1267
+ IDBook: i,
1268
+ GUIDBook: `GUID-BOOK-${i}`,
1269
+ CreateDate: '2025-01-01T00:00:00.000Z',
1270
+ CreatingIDUser: 1,
1271
+ UpdateDate: '2025-03-01T00:00:00.000Z',
1272
+ UpdatingIDUser: 1,
1273
+ Deleted: 1,
1274
+ DeleteDate: '2025-04-01T00:00:00.000Z',
1275
+ DeletingIDUser: 1,
1276
+ Title: `Deleted-Book-${i}`,
1277
+ Type: 'Fiction',
1278
+ Genre: GENRES[i % GENRES.length],
1279
+ PublicationYear: 2000 + (i % 26)
1280
+ });
1281
+ }
1282
+ _MockServerData.Books = tmpActiveBooks.concat(tmpDeletedBooks);
1283
+
1284
+ let tmpFable = createTestFable();
1285
+ setupSQLiteProvider(tmpFable,
1286
+ (pError) =>
1287
+ {
1288
+ Expect(pError).to.not.exist;
1289
+
1290
+ // Seed local with only the active records (simulating
1291
+ // an older version that never synced deleted records)
1292
+ seedLocalBooks(tmpFable, tmpActiveBooks);
1293
+ Expect(getLocalBookCount(tmpFable)).to.equal(RECORD_COUNT);
1294
+
1295
+ resetRequestLog();
1296
+
1297
+ // Now run ongoing sync WITH SyncDeletedRecords enabled
1298
+ setupSyncServices(tmpFable, 'Ongoing',
1299
+ (pError) =>
1300
+ {
1301
+ Expect(pError).to.not.exist;
1302
+ tmpFable.MeadowSync.syncAll(
1303
+ (pSyncError) =>
1304
+ {
1305
+ Expect(pSyncError).to.not.exist;
1306
+
1307
+ // The 100 deleted records should now exist locally
1308
+ let tmpDeletedLocal = tmpFable.MeadowSQLiteProvider.db
1309
+ .prepare('SELECT COUNT(*) AS cnt FROM Book WHERE Deleted = 1')
1310
+ .get().cnt;
1311
+ Expect(tmpDeletedLocal).to.equal(100,
1312
+ 'All 100 deleted server records should be created locally');
1313
+
1314
+ // Verify a specific deleted record
1315
+ let tmpDeletedBook = getLocalBook(tmpFable, RECORD_COUNT + 50);
1316
+ Expect(tmpDeletedBook).to.not.be.undefined;
1317
+ Expect(tmpDeletedBook.Deleted).to.equal(1);
1318
+ Expect(tmpDeletedBook.Title).to.equal(`Deleted-Book-${RECORD_COUNT + 50}`);
1319
+
1320
+ // Active records should still be intact
1321
+ Expect(getLocalBookCount(tmpFable)).to.equal(RECORD_COUNT);
1322
+
1323
+ return fDone();
1324
+ });
1325
+ },
1326
+ { SyncDeletedRecords: true });
1327
+ });
1328
+ }
1329
+ );
1330
+
1331
+ test
1332
+ (
1333
+ 'Should mark existing active records as deleted when server has them deleted',
1334
+ function (fDone)
1335
+ {
1336
+ this.timeout(120000);
1337
+
1338
+ // Start with 5000 active records on both sides
1339
+ let tmpBooks = generateBooks(RECORD_COUNT, BASE_UPDATE_DATE);
1340
+ _MockServerData.Books = tmpBooks.map((b) => Object.assign({}, b));
1341
+
1342
+ let tmpFable = createTestFable();
1343
+ setupSQLiteProvider(tmpFable,
1344
+ (pError) =>
1345
+ {
1346
+ Expect(pError).to.not.exist;
1347
+ seedLocalBooks(tmpFable, tmpBooks);
1348
+
1349
+ // Now delete 20 records on the server (IDs 100-119)
1350
+ for (let i = 99; i < 119; i++)
1351
+ {
1352
+ _MockServerData.Books[i].Deleted = 1;
1353
+ _MockServerData.Books[i].DeleteDate = '2025-08-01T00:00:00.000Z';
1354
+ _MockServerData.Books[i].DeletingIDUser = 1;
1355
+ _MockServerData.Books[i].UpdateDate = NEWER_UPDATE_DATE;
1356
+ }
1357
+
1358
+ resetRequestLog();
1359
+
1360
+ // Run ongoing sync with SyncDeletedRecords
1361
+ setupSyncServices(tmpFable, 'Ongoing',
1362
+ (pError) =>
1363
+ {
1364
+ Expect(pError).to.not.exist;
1365
+ tmpFable.MeadowSync.syncAll(
1366
+ (pSyncError) =>
1367
+ {
1368
+ Expect(pSyncError).to.not.exist;
1369
+
1370
+ // The 20 records should now be marked deleted locally
1371
+ let tmpDeletedLocal = tmpFable.MeadowSQLiteProvider.db
1372
+ .prepare('SELECT COUNT(*) AS cnt FROM Book WHERE Deleted = 1')
1373
+ .get().cnt;
1374
+ Expect(tmpDeletedLocal).to.equal(20,
1375
+ '20 records should be marked as deleted locally');
1376
+
1377
+ // Verify a specific record was soft-deleted
1378
+ let tmpDeletedBook = getLocalBook(tmpFable, 110);
1379
+ Expect(tmpDeletedBook.Deleted).to.equal(1);
1380
+
1381
+ // Non-deleted records should be untouched
1382
+ let tmpActiveBook = getLocalBook(tmpFable, 200);
1383
+ Expect(tmpActiveBook.Deleted).to.equal(0);
1384
+
1385
+ return fDone();
1386
+ });
1387
+ },
1388
+ { SyncDeletedRecords: true });
1389
+ });
1390
+ }
1391
+ );
1392
+
1393
+ test
1394
+ (
1395
+ 'Should handle mixed scenario: new deleted records + existing records to mark deleted',
1396
+ function (fDone)
1397
+ {
1398
+ this.timeout(120000);
1399
+
1400
+ let tmpActiveBooks = generateBooks(RECORD_COUNT, BASE_UPDATE_DATE);
1401
+
1402
+ // Server: 5000 active, 50 soft-deleted within the range (IDs 500-549),
1403
+ // plus 50 deleted-only records at the end (IDs 5001-5050) never synced
1404
+ let tmpServerBooks = tmpActiveBooks.map((b) => Object.assign({}, b));
1405
+
1406
+ // Mark 500-549 as deleted on server
1407
+ for (let i = 499; i < 549; i++)
1408
+ {
1409
+ tmpServerBooks[i].Deleted = 1;
1410
+ tmpServerBooks[i].DeleteDate = '2025-08-01T00:00:00.000Z';
1411
+ tmpServerBooks[i].DeletingIDUser = 1;
1412
+ tmpServerBooks[i].UpdateDate = NEWER_UPDATE_DATE;
1413
+ }
1414
+
1415
+ // Add 50 deleted-only records at the end
1416
+ for (let i = RECORD_COUNT + 1; i <= RECORD_COUNT + 50; i++)
1417
+ {
1418
+ tmpServerBooks.push(
1419
+ {
1420
+ IDBook: i,
1421
+ GUIDBook: `GUID-BOOK-${i}`,
1422
+ CreateDate: '2025-01-01T00:00:00.000Z',
1423
+ CreatingIDUser: 1,
1424
+ UpdateDate: '2025-03-01T00:00:00.000Z',
1425
+ UpdatingIDUser: 1,
1426
+ Deleted: 1,
1427
+ DeleteDate: '2025-04-01T00:00:00.000Z',
1428
+ DeletingIDUser: 1,
1429
+ Title: `Deleted-Book-${i}`,
1430
+ Type: 'Fiction',
1431
+ Genre: GENRES[i % GENRES.length],
1432
+ PublicationYear: 2000 + (i % 26)
1433
+ });
1434
+ }
1435
+
1436
+ _MockServerData.Books = tmpServerBooks;
1437
+
1438
+ let tmpFable = createTestFable();
1439
+ setupSQLiteProvider(tmpFable,
1440
+ (pError) =>
1441
+ {
1442
+ Expect(pError).to.not.exist;
1443
+
1444
+ // Local only has the 5000 active records (old version, never synced deletes)
1445
+ seedLocalBooks(tmpFable, tmpActiveBooks);
1446
+
1447
+ resetRequestLog();
1448
+
1449
+ setupSyncServices(tmpFable, 'Ongoing',
1450
+ (pError) =>
1451
+ {
1452
+ Expect(pError).to.not.exist;
1453
+ tmpFable.MeadowSync.syncAll(
1454
+ (pSyncError) =>
1455
+ {
1456
+ Expect(pSyncError).to.not.exist;
1457
+
1458
+ let tmpDeletedLocal = tmpFable.MeadowSQLiteProvider.db
1459
+ .prepare('SELECT COUNT(*) AS cnt FROM Book WHERE Deleted = 1')
1460
+ .get().cnt;
1461
+ // 50 existing records marked deleted + 50 new deleted records created
1462
+ Expect(tmpDeletedLocal).to.equal(100,
1463
+ 'Should have 100 total deleted records locally');
1464
+
1465
+ // Verify a record that was active but now deleted
1466
+ let tmpMarkedDeleted = getLocalBook(tmpFable, 525);
1467
+ Expect(tmpMarkedDeleted.Deleted).to.equal(1);
1468
+
1469
+ // Verify a created-as-deleted record
1470
+ let tmpCreatedDeleted = getLocalBook(tmpFable, RECORD_COUNT + 25);
1471
+ Expect(tmpCreatedDeleted).to.not.be.undefined;
1472
+ Expect(tmpCreatedDeleted.Deleted).to.equal(1);
1473
+ Expect(tmpCreatedDeleted.Title).to.equal(`Deleted-Book-${RECORD_COUNT + 25}`);
1474
+
1475
+ // Active records outside the deleted range should be fine
1476
+ let tmpStillActive = getLocalBook(tmpFable, 600);
1477
+ Expect(tmpStillActive.Deleted).to.equal(0);
1478
+
1479
+ return fDone();
1480
+ });
1481
+ },
1482
+ { SyncDeletedRecords: true });
1483
+ });
1484
+ }
1485
+ );
1486
+ }
1487
+ );
1488
+
1489
+ // ── Efficiency — Verify bisection scales logarithmically ─────────────
1490
+
1491
+ suite
1492
+ (
1493
+ 'Bisection Efficiency',
1494
+ () =>
1495
+ {
1496
+ test
1497
+ (
1498
+ 'Unchanged data should require O(1) API calls regardless of dataset size',
1499
+ function (fDone)
1500
+ {
1501
+ this.timeout(120000);
1502
+
1503
+ let tmpBooks = generateBooks(RECORD_COUNT, BASE_UPDATE_DATE);
1504
+ _MockServerData.Books = tmpBooks.map((b) => Object.assign({}, b));
1505
+
1506
+ let tmpFable = createTestFable();
1507
+ setupSQLiteProvider(tmpFable,
1508
+ (pError) =>
1509
+ {
1510
+ Expect(pError).to.not.exist;
1511
+ seedLocalBooks(tmpFable, tmpBooks);
1512
+
1513
+ resetRequestLog();
1514
+
1515
+ setupSyncServices(tmpFable, 'Ongoing',
1516
+ (pError) =>
1517
+ {
1518
+ Expect(pError).to.not.exist;
1519
+ tmpFable.MeadowSync.syncAll(
1520
+ (pSyncError) =>
1521
+ {
1522
+ Expect(pSyncError).to.not.exist;
1523
+
1524
+ let tmpLog = _MockServerData.RequestLog;
1525
+
1526
+ // For unchanged data:
1527
+ // - Stage 3 (UpdateDate fast-sync): 1 count request (filtered)
1528
+ // → counts match → ExistingRecordsInSync=true
1529
+ // → 1 count for UpdateDate GT → 0 new records
1530
+ // - No bisection, no record pulls
1531
+ // Total filtered count requests should be very small
1532
+ Expect(tmpLog.countFilteredRequests).to.be.at.most(5,
1533
+ 'Unchanged data should need very few filtered count queries');
1534
+ Expect(tmpLog.recordPullRequests).to.equal(0,
1535
+ 'Unchanged data should not trigger any record pull requests');
1536
+ Expect(tmpLog.totalRecordsPulled).to.equal(0,
1537
+ 'Unchanged data should pull zero records');
1538
+
1539
+ return fDone();
1540
+ });
1541
+ });
1542
+ });
1543
+ }
1544
+ );
1545
+
1546
+ test
1547
+ (
1548
+ 'Small changes should require far fewer API calls than dataset size',
1549
+ function (fDone)
1550
+ {
1551
+ this.timeout(120000);
1552
+
1553
+ let tmpBooks = generateBooks(RECORD_COUNT, BASE_UPDATE_DATE);
1554
+ _MockServerData.Books = tmpBooks.map((b) => Object.assign({}, b));
1555
+
1556
+ let tmpFable = createTestFable();
1557
+ setupSQLiteProvider(tmpFable,
1558
+ (pError) =>
1559
+ {
1560
+ Expect(pError).to.not.exist;
1561
+ seedLocalBooks(tmpFable, tmpBooks);
1562
+
1563
+ // Change just 10 records
1564
+ mutateBooks(_MockServerData.Books, 100, 109, NEWER_UPDATE_DATE, 'Efficiency');
1565
+
1566
+ resetRequestLog();
1567
+
1568
+ setupSyncServices(tmpFable, 'Ongoing',
1569
+ (pError) =>
1570
+ {
1571
+ Expect(pError).to.not.exist;
1572
+ tmpFable.MeadowSync.syncAll(
1573
+ (pSyncError) =>
1574
+ {
1575
+ Expect(pSyncError).to.not.exist;
1576
+
1577
+ let tmpEntity = tmpFable.MeadowSync.MeadowSyncEntities['Book'];
1578
+ Expect(tmpEntity.syncResults.Updated).to.equal(10);
1579
+
1580
+ let tmpLog = _MockServerData.RequestLog;
1581
+
1582
+ // With 10 changes out of 5000, the UpdateDate fast-sync
1583
+ // should pull just the 10 changed records directly.
1584
+ // Total API calls should be well under 50 (vs 5000/100 = 50 pages for full scan)
1585
+ let tmpTotalAPICalls = tmpLog.countRequests + tmpLog.countFilteredRequests
1586
+ + tmpLog.recordPullRequests + tmpLog.maxIDRequests;
1587
+ Expect(tmpTotalAPICalls).to.be.below(20,
1588
+ '10 changes out of 5000 should need very few API calls');
1589
+ Expect(tmpLog.totalRecordsPulled).to.be.below(50,
1590
+ 'Should pull close to just the 10 changed records');
1591
+
1592
+ return fDone();
1593
+ });
1594
+ });
1595
+ });
1596
+ }
1597
+ );
1598
+ }
1599
+ );
1600
+ }
1601
+ );