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
|
+
);
|