meadow-integration 1.0.23 → 1.0.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/_cover.md +1 -1
- package/docs/_version.json +7 -0
- package/docs/css/docuserve.css +277 -23
- package/docs/index.html +2 -2
- package/docs/retold-catalog.json +40 -1
- package/docs/retold-keyword-index.json +6150 -5279
- package/package.json +3 -2
- package/source/Meadow-Service-Integration-Adapter.js +5 -8
- package/source/services/clone/Meadow-Service-Sync-Entity-ComparisonOnly.js +435 -0
- package/source/services/clone/Meadow-Service-Sync-Entity-OngoingEventualConsistency.js +353 -0
- package/source/services/clone/Meadow-Service-Sync-Entity-TrueUp.js +199 -0
- package/source/services/clone/Meadow-Service-Sync.js +61 -6
- package/test/Meadow-Integration-Adapter_test.js +431 -48
- package/test/Meadow-Integration-ComprehensionPush_test.js +102 -8
- package/test/Meadow-Integration-NewStrategies_test.js +1265 -0
|
@@ -0,0 +1,1265 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Unit tests for the three new clone sync strategies:
|
|
3
|
+
|
|
4
|
+
1. OngoingEventualConsistency — time-budgeted backwards bisection
|
|
5
|
+
2. TrueUp — linear keyset-paginated walk
|
|
6
|
+
3. ComparisonOnly — bisection-based diff report (no sync)
|
|
7
|
+
|
|
8
|
+
Uses a 50,000-record dataset with mixed fragmentation:
|
|
9
|
+
- 200 scattered updates (every 250th ID)
|
|
10
|
+
- 500-record contiguous gap (local missing IDs 20001-20500)
|
|
11
|
+
- 500 new records at the tail (IDs 50001-50500)
|
|
12
|
+
- 100 deleted records (IDs 50501-50600)
|
|
13
|
+
|
|
14
|
+
Infrastructure mirrors Meadow-Integration-BisectionSync_test.js:
|
|
15
|
+
filter-aware mock HTTP server + in-memory SQLite.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const Chai = require('chai');
|
|
19
|
+
const Expect = Chai.expect;
|
|
20
|
+
|
|
21
|
+
const libHTTP = require('http');
|
|
22
|
+
const libFable = require('fable');
|
|
23
|
+
const libMeadow = require('meadow');
|
|
24
|
+
const libMeadowConnectionSQLite = require('meadow-connection-sqlite');
|
|
25
|
+
|
|
26
|
+
const libMeadowCloneRestClient = require('../source/services/clone/Meadow-Service-RestClient.js');
|
|
27
|
+
const libMeadowSync = require('../source/services/clone/Meadow-Service-Sync.js');
|
|
28
|
+
const libMeadowSyncEntityInitial = require('../source/services/clone/Meadow-Service-Sync-Entity-Initial.js');
|
|
29
|
+
const libMeadowSyncEntityOngoing = require('../source/services/clone/Meadow-Service-Sync-Entity-Ongoing.js');
|
|
30
|
+
const libMeadowSyncEntityOngoingEventualConsistency = require('../source/services/clone/Meadow-Service-Sync-Entity-OngoingEventualConsistency.js');
|
|
31
|
+
const libMeadowSyncEntityTrueUp = require('../source/services/clone/Meadow-Service-Sync-Entity-TrueUp.js');
|
|
32
|
+
const libMeadowSyncEntityComparisonOnly = require('../source/services/clone/Meadow-Service-Sync-Entity-ComparisonOnly.js');
|
|
33
|
+
|
|
34
|
+
// ── Test Constants ──────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
const MOCK_PORT = 18200;
|
|
37
|
+
const MOCK_BASE_URL = `http://localhost:${MOCK_PORT}/1.0/`;
|
|
38
|
+
|
|
39
|
+
const BASE_UPDATE_DATE = '2025-06-15T12:00:00.000Z';
|
|
40
|
+
const NEWER_UPDATE_DATE = '2025-07-01T12:00:00.000Z';
|
|
41
|
+
const NEWEST_UPDATE_DATE = '2025-08-01T12:00:00.000Z';
|
|
42
|
+
|
|
43
|
+
const RECORD_COUNT = 50000;
|
|
44
|
+
const BISECT_MIN_RANGE = 1000;
|
|
45
|
+
|
|
46
|
+
// Fragmentation parameters
|
|
47
|
+
const GAP_START = 20001;
|
|
48
|
+
const GAP_END = 20500;
|
|
49
|
+
const GAP_SIZE = GAP_END - GAP_START + 1;
|
|
50
|
+
const NEW_RECORDS_START = RECORD_COUNT + 1;
|
|
51
|
+
const NEW_RECORDS_END = RECORD_COUNT + 500;
|
|
52
|
+
const NEW_RECORDS_COUNT = NEW_RECORDS_END - NEW_RECORDS_START + 1;
|
|
53
|
+
const DELETED_START = NEW_RECORDS_END + 1;
|
|
54
|
+
const DELETED_END = DELETED_START + 99;
|
|
55
|
+
const DELETED_COUNT = DELETED_END - DELETED_START + 1;
|
|
56
|
+
const SCATTERED_UPDATE_INTERVAL = 5000;
|
|
57
|
+
const SCATTERED_UPDATE_COUNT = Math.floor(RECORD_COUNT / SCATTERED_UPDATE_INTERVAL);
|
|
58
|
+
|
|
59
|
+
// ── Book Entity Schema (Extended Format) ────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
const _BookExtendedSchema =
|
|
62
|
+
{
|
|
63
|
+
Tables:
|
|
64
|
+
{
|
|
65
|
+
Book:
|
|
66
|
+
{
|
|
67
|
+
TableName: 'Book',
|
|
68
|
+
Columns:
|
|
69
|
+
[
|
|
70
|
+
{ Column: 'IDBook', DataType: 'int' },
|
|
71
|
+
{ Column: 'GUIDBook', DataType: 'GUID' },
|
|
72
|
+
{ Column: 'CreateDate', DataType: 'DateTime' },
|
|
73
|
+
{ Column: 'CreatingIDUser', DataType: 'int' },
|
|
74
|
+
{ Column: 'UpdateDate', DataType: 'DateTime' },
|
|
75
|
+
{ Column: 'UpdatingIDUser', DataType: 'int' },
|
|
76
|
+
{ Column: 'Deleted', DataType: 'int' },
|
|
77
|
+
{ Column: 'DeleteDate', DataType: 'DateTime' },
|
|
78
|
+
{ Column: 'DeletingIDUser', DataType: 'int' },
|
|
79
|
+
{ Column: 'Title', DataType: 'String' },
|
|
80
|
+
{ Column: 'Type', DataType: 'String' },
|
|
81
|
+
{ Column: 'Genre', DataType: 'String' },
|
|
82
|
+
{ Column: 'PublicationYear', DataType: 'int' }
|
|
83
|
+
],
|
|
84
|
+
MeadowSchema:
|
|
85
|
+
{
|
|
86
|
+
Scope: 'Book',
|
|
87
|
+
DefaultIdentifier: 'IDBook',
|
|
88
|
+
Domain: 'Default',
|
|
89
|
+
Schema:
|
|
90
|
+
[
|
|
91
|
+
{ Column: 'IDBook', Type: 'AutoIdentity', Size: 'Default' },
|
|
92
|
+
{ Column: 'GUIDBook', Type: 'AutoGUID', Size: '128' },
|
|
93
|
+
{ Column: 'CreateDate', Type: 'CreateDate', Size: 'Default' },
|
|
94
|
+
{ Column: 'CreatingIDUser', Type: 'CreateIDUser', Size: 'int' },
|
|
95
|
+
{ Column: 'UpdateDate', Type: 'UpdateDate', Size: 'Default' },
|
|
96
|
+
{ Column: 'UpdatingIDUser', Type: 'UpdateIDUser', Size: 'int' },
|
|
97
|
+
{ Column: 'Deleted', Type: 'Deleted', Size: 'Default' },
|
|
98
|
+
{ Column: 'DeleteDate', Type: 'DeleteDate', Size: 'Default' },
|
|
99
|
+
{ Column: 'DeletingIDUser', Type: 'DeleteIDUser', Size: 'int' },
|
|
100
|
+
{ Column: 'Title', Type: 'String', Size: '200' },
|
|
101
|
+
{ Column: 'Type', Type: 'String', Size: '32' },
|
|
102
|
+
{ Column: 'Genre', Type: 'String', Size: '128' },
|
|
103
|
+
{ Column: 'PublicationYear', Type: 'Integer', Size: 'int' }
|
|
104
|
+
],
|
|
105
|
+
DefaultObject:
|
|
106
|
+
{
|
|
107
|
+
IDBook: 0, GUIDBook: '', CreateDate: null, CreatingIDUser: 0,
|
|
108
|
+
UpdateDate: null, UpdatingIDUser: 0, Deleted: 0,
|
|
109
|
+
DeleteDate: null, DeletingIDUser: 0,
|
|
110
|
+
Title: '', Type: '', Genre: '', PublicationYear: 0
|
|
111
|
+
},
|
|
112
|
+
JsonSchema:
|
|
113
|
+
{
|
|
114
|
+
title: 'Book',
|
|
115
|
+
type: 'object',
|
|
116
|
+
properties:
|
|
117
|
+
{
|
|
118
|
+
IDBook: { type: 'integer' },
|
|
119
|
+
GUIDBook: { type: 'string' },
|
|
120
|
+
CreateDate: { type: 'string' },
|
|
121
|
+
CreatingIDUser: { type: 'integer' },
|
|
122
|
+
UpdateDate: { type: 'string' },
|
|
123
|
+
UpdatingIDUser: { type: 'integer' },
|
|
124
|
+
Deleted: { type: 'boolean' },
|
|
125
|
+
DeleteDate: { type: 'string' },
|
|
126
|
+
DeletingIDUser: { type: 'integer' },
|
|
127
|
+
Title: { type: 'string' },
|
|
128
|
+
Type: { type: 'string' },
|
|
129
|
+
Genre: { type: 'string' },
|
|
130
|
+
PublicationYear: { type: 'integer' }
|
|
131
|
+
},
|
|
132
|
+
required: ['IDBook']
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// ── Deterministic Data Generator ────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
const GENRES = ['Adventure', 'Mystery', 'Science', 'Romance', 'Technology',
|
|
142
|
+
'Fantasy', 'History', 'Biography', 'Horror', 'Comedy'];
|
|
143
|
+
|
|
144
|
+
function generateBooks(pCount, pBaseUpdateDate)
|
|
145
|
+
{
|
|
146
|
+
let tmpBooks = [];
|
|
147
|
+
for (let i = 1; i <= pCount; i++)
|
|
148
|
+
{
|
|
149
|
+
tmpBooks.push(
|
|
150
|
+
{
|
|
151
|
+
IDBook: i,
|
|
152
|
+
GUIDBook: `GUID-BOOK-${i}`,
|
|
153
|
+
CreateDate: '2025-01-01T00:00:00.000Z',
|
|
154
|
+
CreatingIDUser: 1,
|
|
155
|
+
UpdateDate: pBaseUpdateDate,
|
|
156
|
+
UpdatingIDUser: 1,
|
|
157
|
+
Deleted: 0,
|
|
158
|
+
DeleteDate: '',
|
|
159
|
+
DeletingIDUser: 0,
|
|
160
|
+
Title: `Book-${i}`,
|
|
161
|
+
Type: 'Fiction',
|
|
162
|
+
Genre: GENRES[i % GENRES.length],
|
|
163
|
+
PublicationYear: 2000 + (i % 26)
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return tmpBooks;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function mutateBooks(pBooks, pStartID, pEndID, pNewUpdateDate, pTitlePrefix)
|
|
170
|
+
{
|
|
171
|
+
for (let i = 0; i < pBooks.length; i++)
|
|
172
|
+
{
|
|
173
|
+
if (pBooks[i].IDBook >= pStartID && pBooks[i].IDBook <= pEndID)
|
|
174
|
+
{
|
|
175
|
+
pBooks[i].UpdateDate = pNewUpdateDate;
|
|
176
|
+
if (pTitlePrefix)
|
|
177
|
+
{
|
|
178
|
+
pBooks[i].Title = `${pTitlePrefix}-${pBooks[i].IDBook}`;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Apply scattered updates: every SCATTERED_UPDATE_INTERVAL-th ID
|
|
185
|
+
function applyScatteredUpdates(pBooks)
|
|
186
|
+
{
|
|
187
|
+
for (let i = 0; i < pBooks.length; i++)
|
|
188
|
+
{
|
|
189
|
+
if (pBooks[i].IDBook % SCATTERED_UPDATE_INTERVAL === 0 && pBooks[i].Deleted === 0)
|
|
190
|
+
{
|
|
191
|
+
pBooks[i].UpdateDate = NEWER_UPDATE_DATE;
|
|
192
|
+
pBooks[i].Title = `Scattered-${pBooks[i].IDBook}`;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Build the full fragmented server dataset
|
|
198
|
+
function buildFragmentedServerData()
|
|
199
|
+
{
|
|
200
|
+
// 50,000 active records
|
|
201
|
+
let tmpBooks = generateBooks(RECORD_COUNT, BASE_UPDATE_DATE);
|
|
202
|
+
|
|
203
|
+
// Scattered updates (every 250th ID gets a newer UpdateDate)
|
|
204
|
+
applyScatteredUpdates(tmpBooks);
|
|
205
|
+
|
|
206
|
+
// 500 new records at the tail
|
|
207
|
+
for (let i = NEW_RECORDS_START; i <= NEW_RECORDS_END; i++)
|
|
208
|
+
{
|
|
209
|
+
tmpBooks.push(
|
|
210
|
+
{
|
|
211
|
+
IDBook: i,
|
|
212
|
+
GUIDBook: `GUID-BOOK-${i}`,
|
|
213
|
+
CreateDate: '2025-07-01T00:00:00.000Z',
|
|
214
|
+
CreatingIDUser: 1,
|
|
215
|
+
UpdateDate: NEWEST_UPDATE_DATE,
|
|
216
|
+
UpdatingIDUser: 1,
|
|
217
|
+
Deleted: 0,
|
|
218
|
+
DeleteDate: '',
|
|
219
|
+
DeletingIDUser: 0,
|
|
220
|
+
Title: `NewBook-${i}`,
|
|
221
|
+
Type: 'Fiction',
|
|
222
|
+
Genre: GENRES[i % GENRES.length],
|
|
223
|
+
PublicationYear: 2000 + (i % 26)
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 100 deleted records
|
|
228
|
+
for (let i = DELETED_START; i <= DELETED_END; i++)
|
|
229
|
+
{
|
|
230
|
+
tmpBooks.push(
|
|
231
|
+
{
|
|
232
|
+
IDBook: i,
|
|
233
|
+
GUIDBook: `GUID-BOOK-${i}`,
|
|
234
|
+
CreateDate: '2025-01-01T00:00:00.000Z',
|
|
235
|
+
CreatingIDUser: 1,
|
|
236
|
+
UpdateDate: '2025-03-01T00:00:00.000Z',
|
|
237
|
+
UpdatingIDUser: 1,
|
|
238
|
+
Deleted: 1,
|
|
239
|
+
DeleteDate: '2025-04-01T00:00:00.000Z',
|
|
240
|
+
DeletingIDUser: 1,
|
|
241
|
+
Title: `Deleted-Book-${i}`,
|
|
242
|
+
Type: 'Fiction',
|
|
243
|
+
Genre: GENRES[i % GENRES.length],
|
|
244
|
+
PublicationYear: 2000 + (i % 26)
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return tmpBooks;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Build local dataset: 50,000 records minus the gap (IDs 20001-20500)
|
|
252
|
+
function buildLocalData()
|
|
253
|
+
{
|
|
254
|
+
let tmpBooks = generateBooks(RECORD_COUNT, BASE_UPDATE_DATE);
|
|
255
|
+
return tmpBooks.filter((b) => b.IDBook < GAP_START || b.IDBook > GAP_END);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── FBV~ Filter Parser ──────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
function parseFilter(pFilterString)
|
|
261
|
+
{
|
|
262
|
+
if (!pFilterString) return { filters: [], sort: null };
|
|
263
|
+
|
|
264
|
+
let tmpFilterPart = pFilterString;
|
|
265
|
+
let tmpSort = null;
|
|
266
|
+
|
|
267
|
+
let tmpFSFIndex = tmpFilterPart.indexOf('~FSF~');
|
|
268
|
+
if (tmpFSFIndex >= 0)
|
|
269
|
+
{
|
|
270
|
+
let tmpSortPart = tmpFilterPart.substring(tmpFSFIndex + 5);
|
|
271
|
+
tmpFilterPart = tmpFilterPart.substring(0, tmpFSFIndex);
|
|
272
|
+
let tmpSortTokens = tmpSortPart.split('~');
|
|
273
|
+
if (tmpSortTokens.length >= 2)
|
|
274
|
+
{
|
|
275
|
+
tmpSort = { Column: tmpSortTokens[0], Direction: tmpSortTokens[1] };
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (tmpFilterPart.indexOf('FSF~') === 0)
|
|
280
|
+
{
|
|
281
|
+
let tmpSortTokens = tmpFilterPart.substring(4).split('~');
|
|
282
|
+
if (tmpSortTokens.length >= 2)
|
|
283
|
+
{
|
|
284
|
+
tmpSort = { Column: tmpSortTokens[0], Direction: tmpSortTokens[1] };
|
|
285
|
+
}
|
|
286
|
+
return { filters: [], sort: tmpSort };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
let tmpFilters = [];
|
|
290
|
+
if (tmpFilterPart.indexOf('FBV~') === 0)
|
|
291
|
+
{
|
|
292
|
+
tmpFilterPart = tmpFilterPart.substring(4);
|
|
293
|
+
}
|
|
294
|
+
let tmpClauses = tmpFilterPart.split('~FBV~');
|
|
295
|
+
for (let i = 0; i < tmpClauses.length; i++)
|
|
296
|
+
{
|
|
297
|
+
let tmpTokens = tmpClauses[i].split('~');
|
|
298
|
+
if (tmpTokens.length >= 3)
|
|
299
|
+
{
|
|
300
|
+
tmpFilters.push({ Column: tmpTokens[0], Operator: tmpTokens[1], Value: tmpTokens.slice(2).join('~') });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return { filters: tmpFilters, sort: tmpSort };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function applyFilters(pBooks, pParsed)
|
|
308
|
+
{
|
|
309
|
+
let tmpResult = pBooks;
|
|
310
|
+
|
|
311
|
+
for (let i = 0; i < pParsed.filters.length; i++)
|
|
312
|
+
{
|
|
313
|
+
let tmpFilter = pParsed.filters[i];
|
|
314
|
+
let tmpCol = tmpFilter.Column;
|
|
315
|
+
let tmpOp = tmpFilter.Operator;
|
|
316
|
+
let tmpVal = tmpFilter.Value;
|
|
317
|
+
|
|
318
|
+
tmpResult = tmpResult.filter((pBook) =>
|
|
319
|
+
{
|
|
320
|
+
let tmpBookVal = pBook[tmpCol];
|
|
321
|
+
if (tmpBookVal === undefined || tmpBookVal === null) return false;
|
|
322
|
+
|
|
323
|
+
let tmpCompareBookVal = String(tmpBookVal).replace(/Z$/, '');
|
|
324
|
+
let tmpCompareFilterVal = String(tmpVal).replace(/Z$/, '');
|
|
325
|
+
|
|
326
|
+
if (tmpCol === 'IDBook' || tmpCol === 'CreatingIDUser' || tmpCol === 'UpdatingIDUser' ||
|
|
327
|
+
tmpCol === 'DeletingIDUser' || tmpCol === 'PublicationYear' || tmpCol === 'Deleted')
|
|
328
|
+
{
|
|
329
|
+
tmpCompareBookVal = Number(tmpBookVal);
|
|
330
|
+
tmpCompareFilterVal = Number(tmpVal);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
switch (tmpOp)
|
|
334
|
+
{
|
|
335
|
+
case 'GE': return tmpCompareBookVal >= tmpCompareFilterVal;
|
|
336
|
+
case 'LE': return tmpCompareBookVal <= tmpCompareFilterVal;
|
|
337
|
+
case 'GT': return tmpCompareBookVal > tmpCompareFilterVal;
|
|
338
|
+
case 'LT': return tmpCompareBookVal < tmpCompareFilterVal;
|
|
339
|
+
case 'EQ': return tmpCompareBookVal == tmpCompareFilterVal;
|
|
340
|
+
default: return true;
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (pParsed.sort)
|
|
346
|
+
{
|
|
347
|
+
let tmpSortCol = pParsed.sort.Column;
|
|
348
|
+
let tmpDir = (pParsed.sort.Direction || '').toUpperCase() === 'DESC' ? -1 : 1;
|
|
349
|
+
tmpResult.sort((a, b) =>
|
|
350
|
+
{
|
|
351
|
+
let tmpA = a[tmpSortCol];
|
|
352
|
+
let tmpB = b[tmpSortCol];
|
|
353
|
+
if (tmpA < tmpB) return -1 * tmpDir;
|
|
354
|
+
if (tmpA > tmpB) return 1 * tmpDir;
|
|
355
|
+
return 0;
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return tmpResult;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ── Mock HTTP Server (Filter-Aware) ─────────────────────────────────────────────
|
|
363
|
+
|
|
364
|
+
let _MockServerData =
|
|
365
|
+
{
|
|
366
|
+
Books: [],
|
|
367
|
+
RequestLog:
|
|
368
|
+
{
|
|
369
|
+
maxIDRequests: 0,
|
|
370
|
+
countRequests: 0,
|
|
371
|
+
countFilteredRequests: 0,
|
|
372
|
+
recordPullRequests: 0,
|
|
373
|
+
totalRecordsPulled: 0
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
function resetRequestLog()
|
|
378
|
+
{
|
|
379
|
+
_MockServerData.RequestLog =
|
|
380
|
+
{
|
|
381
|
+
maxIDRequests: 0,
|
|
382
|
+
countRequests: 0,
|
|
383
|
+
countFilteredRequests: 0,
|
|
384
|
+
recordPullRequests: 0,
|
|
385
|
+
totalRecordsPulled: 0
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function createMockServer()
|
|
390
|
+
{
|
|
391
|
+
return libHTTP.createServer(
|
|
392
|
+
(pRequest, pResponse) =>
|
|
393
|
+
{
|
|
394
|
+
let tmpURL = pRequest.url.split('?')[0];
|
|
395
|
+
pResponse.setHeader('Content-Type', 'application/json');
|
|
396
|
+
|
|
397
|
+
let tmpBooks = _MockServerData.Books;
|
|
398
|
+
|
|
399
|
+
// GET /1.0/Book/Max/IDBook
|
|
400
|
+
if (tmpURL.match(/\/1\.0\/Book\/Max\/IDBook$/))
|
|
401
|
+
{
|
|
402
|
+
_MockServerData.RequestLog.maxIDRequests++;
|
|
403
|
+
let tmpMaxID = 0;
|
|
404
|
+
for (let i = 0; i < tmpBooks.length; i++)
|
|
405
|
+
{
|
|
406
|
+
if (tmpBooks[i].IDBook > tmpMaxID && tmpBooks[i].Deleted === 0)
|
|
407
|
+
{
|
|
408
|
+
tmpMaxID = tmpBooks[i].IDBook;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
pResponse.end(JSON.stringify({ IDBook: tmpMaxID }));
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// GET /1.0/Book/Max/UpdateDate
|
|
416
|
+
if (tmpURL.match(/\/1\.0\/Book\/Max\/UpdateDate$/))
|
|
417
|
+
{
|
|
418
|
+
let tmpMaxDate = '';
|
|
419
|
+
for (let i = 0; i < tmpBooks.length; i++)
|
|
420
|
+
{
|
|
421
|
+
if (tmpBooks[i].UpdateDate > tmpMaxDate) tmpMaxDate = tmpBooks[i].UpdateDate;
|
|
422
|
+
}
|
|
423
|
+
pResponse.end(JSON.stringify({ UpdateDate: tmpMaxDate }));
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// GET /1.0/Books/Count (unfiltered)
|
|
428
|
+
if (tmpURL.match(/\/1\.0\/Books\/Count$/) && !tmpURL.match(/FilteredTo/))
|
|
429
|
+
{
|
|
430
|
+
_MockServerData.RequestLog.countRequests++;
|
|
431
|
+
// Unfiltered count returns only non-deleted records (meadow default)
|
|
432
|
+
let tmpCount = 0;
|
|
433
|
+
for (let i = 0; i < tmpBooks.length; i++)
|
|
434
|
+
{
|
|
435
|
+
if (tmpBooks[i].Deleted === 0) tmpCount++;
|
|
436
|
+
}
|
|
437
|
+
pResponse.end(JSON.stringify({ Count: tmpCount }));
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// GET /1.0/Books/Count/FilteredTo/<filter>
|
|
442
|
+
let tmpCountFilterMatch = tmpURL.match(/\/1\.0\/Books\/Count\/FilteredTo\/(.+)$/);
|
|
443
|
+
if (tmpCountFilterMatch)
|
|
444
|
+
{
|
|
445
|
+
_MockServerData.RequestLog.countFilteredRequests++;
|
|
446
|
+
let tmpParsed = parseFilter(tmpCountFilterMatch[1]);
|
|
447
|
+
|
|
448
|
+
// Check if the filter explicitly asks for Deleted records
|
|
449
|
+
let tmpExplicitDeleteFilter = tmpParsed.filters.some(
|
|
450
|
+
(f) => f.Column === 'Deleted');
|
|
451
|
+
|
|
452
|
+
let tmpFiltered;
|
|
453
|
+
if (tmpExplicitDeleteFilter)
|
|
454
|
+
{
|
|
455
|
+
tmpFiltered = applyFilters(tmpBooks, tmpParsed);
|
|
456
|
+
}
|
|
457
|
+
else
|
|
458
|
+
{
|
|
459
|
+
// Default: exclude deleted records
|
|
460
|
+
let tmpActive = tmpBooks.filter((b) => b.Deleted === 0);
|
|
461
|
+
tmpFiltered = applyFilters(tmpActive, tmpParsed);
|
|
462
|
+
}
|
|
463
|
+
pResponse.end(JSON.stringify({ Count: tmpFiltered.length }));
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// GET /1.0/Books/FilteredTo/<filter>/<offset>/<pageSize>
|
|
468
|
+
let tmpRecordsFilterMatch = tmpURL.match(/\/1\.0\/Books\/FilteredTo\/(.+)\/(\d+)\/(\d+)$/);
|
|
469
|
+
if (tmpRecordsFilterMatch)
|
|
470
|
+
{
|
|
471
|
+
_MockServerData.RequestLog.recordPullRequests++;
|
|
472
|
+
let tmpFilter = tmpRecordsFilterMatch[1];
|
|
473
|
+
let tmpOffset = parseInt(tmpRecordsFilterMatch[2]);
|
|
474
|
+
let tmpPageSize = parseInt(tmpRecordsFilterMatch[3]);
|
|
475
|
+
let tmpParsed = parseFilter(tmpFilter);
|
|
476
|
+
|
|
477
|
+
// Check if the filter explicitly asks for Deleted records
|
|
478
|
+
let tmpExplicitDeleteFilter = tmpParsed.filters.some(
|
|
479
|
+
(f) => f.Column === 'Deleted');
|
|
480
|
+
|
|
481
|
+
let tmpFiltered;
|
|
482
|
+
if (tmpExplicitDeleteFilter)
|
|
483
|
+
{
|
|
484
|
+
tmpFiltered = applyFilters(tmpBooks, tmpParsed);
|
|
485
|
+
}
|
|
486
|
+
else
|
|
487
|
+
{
|
|
488
|
+
let tmpActive = tmpBooks.filter((b) => b.Deleted === 0);
|
|
489
|
+
tmpFiltered = applyFilters(tmpActive, tmpParsed);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
let tmpPage = tmpFiltered.slice(tmpOffset, tmpOffset + tmpPageSize);
|
|
493
|
+
_MockServerData.RequestLog.totalRecordsPulled += tmpPage.length;
|
|
494
|
+
pResponse.end(JSON.stringify(tmpPage));
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Fallback
|
|
499
|
+
pResponse.statusCode = 404;
|
|
500
|
+
pResponse.end(JSON.stringify({ Error: `Unknown endpoint: ${tmpURL}` }));
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// ── Test Helpers ────────────────────────────────────────────────────────────────
|
|
505
|
+
|
|
506
|
+
function createTestFable()
|
|
507
|
+
{
|
|
508
|
+
let tmpFable = new libFable(
|
|
509
|
+
{
|
|
510
|
+
Product: 'NewStrategiesTest',
|
|
511
|
+
ProductVersion: '1.0.0',
|
|
512
|
+
MeadowProvider: 'SQLite',
|
|
513
|
+
SQLite: { SQLiteFilePath: ':memory:' },
|
|
514
|
+
LogStreams: [{ streamtype: 'console', level: 'error' }]
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
tmpFable.ProgramConfiguration = {};
|
|
518
|
+
|
|
519
|
+
return tmpFable;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function setupSQLiteProvider(pFable, fCallback)
|
|
523
|
+
{
|
|
524
|
+
pFable.serviceManager.addServiceType('MeadowSQLiteProvider', libMeadowConnectionSQLite);
|
|
525
|
+
pFable.serviceManager.instantiateServiceProvider('MeadowSQLiteProvider');
|
|
526
|
+
pFable.MeadowSQLiteProvider.connectAsync(
|
|
527
|
+
(pError) =>
|
|
528
|
+
{
|
|
529
|
+
if (pError) return fCallback(pError);
|
|
530
|
+
|
|
531
|
+
pFable.MeadowSQLiteProvider.db.exec(`
|
|
532
|
+
CREATE TABLE IF NOT EXISTS Book (
|
|
533
|
+
IDBook INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
534
|
+
GUIDBook TEXT DEFAULT '',
|
|
535
|
+
CreateDate TEXT DEFAULT '',
|
|
536
|
+
CreatingIDUser INTEGER DEFAULT 0,
|
|
537
|
+
UpdateDate TEXT DEFAULT '',
|
|
538
|
+
UpdatingIDUser INTEGER DEFAULT 0,
|
|
539
|
+
Deleted INTEGER DEFAULT 0,
|
|
540
|
+
DeleteDate TEXT DEFAULT '',
|
|
541
|
+
DeletingIDUser INTEGER DEFAULT 0,
|
|
542
|
+
Title TEXT DEFAULT '',
|
|
543
|
+
Type TEXT DEFAULT '',
|
|
544
|
+
Genre TEXT DEFAULT '',
|
|
545
|
+
PublicationYear INTEGER DEFAULT 0
|
|
546
|
+
);
|
|
547
|
+
`);
|
|
548
|
+
|
|
549
|
+
return fCallback();
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function seedLocalBooks(pFable, pBooks)
|
|
554
|
+
{
|
|
555
|
+
const tmpInsert = pFable.MeadowSQLiteProvider.db.prepare(`
|
|
556
|
+
INSERT OR REPLACE INTO Book (IDBook, GUIDBook, CreateDate, CreatingIDUser, UpdateDate, UpdatingIDUser, Deleted, DeleteDate, DeletingIDUser, Title, Type, Genre, PublicationYear)
|
|
557
|
+
VALUES (@IDBook, @GUIDBook, @CreateDate, @CreatingIDUser, @UpdateDate, @UpdatingIDUser, @Deleted, @DeleteDate, @DeletingIDUser, @Title, @Type, @Genre, @PublicationYear)
|
|
558
|
+
`);
|
|
559
|
+
const tmpInsertMany = pFable.MeadowSQLiteProvider.db.transaction((pRecords) =>
|
|
560
|
+
{
|
|
561
|
+
for (const tmpRecord of pRecords)
|
|
562
|
+
{
|
|
563
|
+
tmpInsert.run(tmpRecord);
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
tmpInsertMany(pBooks);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function getLocalBookCount(pFable)
|
|
570
|
+
{
|
|
571
|
+
return pFable.MeadowSQLiteProvider.db
|
|
572
|
+
.prepare('SELECT COUNT(*) AS cnt FROM Book WHERE Deleted = 0')
|
|
573
|
+
.get().cnt;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function getLocalBookCountAll(pFable)
|
|
577
|
+
{
|
|
578
|
+
return pFable.MeadowSQLiteProvider.db
|
|
579
|
+
.prepare('SELECT COUNT(*) AS cnt FROM Book')
|
|
580
|
+
.get().cnt;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function getLocalBook(pFable, pID)
|
|
584
|
+
{
|
|
585
|
+
return pFable.MeadowSQLiteProvider.db
|
|
586
|
+
.prepare('SELECT * FROM Book WHERE IDBook = ?')
|
|
587
|
+
.get(pID);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function setupSyncServices(pFable, pSyncMode, fCallback, pExtraOptions)
|
|
591
|
+
{
|
|
592
|
+
if (!pFable.serviceManager.servicesMap.hasOwnProperty('MeadowCloneRestClient'))
|
|
593
|
+
{
|
|
594
|
+
pFable.serviceManager.addServiceType('MeadowCloneRestClient', libMeadowCloneRestClient);
|
|
595
|
+
}
|
|
596
|
+
pFable.serviceManager.instantiateServiceProvider('MeadowCloneRestClient',
|
|
597
|
+
{
|
|
598
|
+
ServerURL: MOCK_BASE_URL
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
if (!pFable.serviceManager.servicesMap.hasOwnProperty('MeadowSync'))
|
|
602
|
+
{
|
|
603
|
+
pFable.serviceManager.addServiceType('MeadowSync', libMeadowSync);
|
|
604
|
+
}
|
|
605
|
+
if (!pFable.serviceManager.servicesMap.hasOwnProperty('MeadowSyncEntityInitial'))
|
|
606
|
+
{
|
|
607
|
+
pFable.serviceManager.addServiceType('MeadowSyncEntityInitial', libMeadowSyncEntityInitial);
|
|
608
|
+
}
|
|
609
|
+
if (!pFable.serviceManager.servicesMap.hasOwnProperty('MeadowSyncEntityOngoing'))
|
|
610
|
+
{
|
|
611
|
+
pFable.serviceManager.addServiceType('MeadowSyncEntityOngoing', libMeadowSyncEntityOngoing);
|
|
612
|
+
}
|
|
613
|
+
if (!pFable.serviceManager.servicesMap.hasOwnProperty('MeadowSyncEntityOngoingEventualConsistency'))
|
|
614
|
+
{
|
|
615
|
+
pFable.serviceManager.addServiceType('MeadowSyncEntityOngoingEventualConsistency', libMeadowSyncEntityOngoingEventualConsistency);
|
|
616
|
+
}
|
|
617
|
+
if (!pFable.serviceManager.servicesMap.hasOwnProperty('MeadowSyncEntityTrueUp'))
|
|
618
|
+
{
|
|
619
|
+
pFable.serviceManager.addServiceType('MeadowSyncEntityTrueUp', libMeadowSyncEntityTrueUp);
|
|
620
|
+
}
|
|
621
|
+
if (!pFable.serviceManager.servicesMap.hasOwnProperty('MeadowSyncEntityComparisonOnly'))
|
|
622
|
+
{
|
|
623
|
+
pFable.serviceManager.addServiceType('MeadowSyncEntityComparisonOnly', libMeadowSyncEntityComparisonOnly);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
let tmpSyncOptions =
|
|
627
|
+
{
|
|
628
|
+
PageSize: 100,
|
|
629
|
+
BisectMinRangeSize: BISECT_MIN_RANGE
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
if (pExtraOptions)
|
|
633
|
+
{
|
|
634
|
+
Object.assign(tmpSyncOptions, pExtraOptions);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
pFable.serviceManager.instantiateServiceProvider('MeadowSync', tmpSyncOptions);
|
|
638
|
+
|
|
639
|
+
pFable.MeadowSync.SyncMode = pSyncMode;
|
|
640
|
+
|
|
641
|
+
pFable.MeadowSync.loadMeadowSchema(_BookExtendedSchema,
|
|
642
|
+
(pError) =>
|
|
643
|
+
{
|
|
644
|
+
return fCallback(pError);
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// ── Test Suite ──────────────────────────────────────────────────────────────────
|
|
649
|
+
|
|
650
|
+
suite
|
|
651
|
+
(
|
|
652
|
+
'New Sync Strategies (50k records)',
|
|
653
|
+
() =>
|
|
654
|
+
{
|
|
655
|
+
let _MockServer = null;
|
|
656
|
+
|
|
657
|
+
suiteSetup
|
|
658
|
+
(
|
|
659
|
+
function (fDone)
|
|
660
|
+
{
|
|
661
|
+
this.timeout(10000);
|
|
662
|
+
_MockServer = createMockServer();
|
|
663
|
+
_MockServer.listen(MOCK_PORT, () => { return fDone(); });
|
|
664
|
+
}
|
|
665
|
+
);
|
|
666
|
+
|
|
667
|
+
suiteTeardown
|
|
668
|
+
(
|
|
669
|
+
(fDone) =>
|
|
670
|
+
{
|
|
671
|
+
if (_MockServer)
|
|
672
|
+
{
|
|
673
|
+
_MockServer.close(fDone);
|
|
674
|
+
}
|
|
675
|
+
else
|
|
676
|
+
{
|
|
677
|
+
return fDone();
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
);
|
|
681
|
+
|
|
682
|
+
// ════════════════════════════════════════════════════════════════════
|
|
683
|
+
// OngoingEventualConsistency
|
|
684
|
+
// ════════════════════════════════════════════════════════════════════
|
|
685
|
+
|
|
686
|
+
suite
|
|
687
|
+
(
|
|
688
|
+
'OngoingEventualConsistency',
|
|
689
|
+
() =>
|
|
690
|
+
{
|
|
691
|
+
test
|
|
692
|
+
(
|
|
693
|
+
'Short time budget (100ms) — should always pull new tail records regardless of budget',
|
|
694
|
+
function (fDone)
|
|
695
|
+
{
|
|
696
|
+
this.timeout(300000);
|
|
697
|
+
|
|
698
|
+
_MockServerData.Books = buildFragmentedServerData();
|
|
699
|
+
let tmpLocalBooks = buildLocalData();
|
|
700
|
+
|
|
701
|
+
let tmpFable = createTestFable();
|
|
702
|
+
setupSQLiteProvider(tmpFable,
|
|
703
|
+
(pError) =>
|
|
704
|
+
{
|
|
705
|
+
Expect(pError).to.not.exist;
|
|
706
|
+
seedLocalBooks(tmpFable, tmpLocalBooks);
|
|
707
|
+
|
|
708
|
+
let tmpLocalCountBefore = getLocalBookCount(tmpFable);
|
|
709
|
+
Expect(tmpLocalCountBefore).to.equal(RECORD_COUNT - GAP_SIZE);
|
|
710
|
+
|
|
711
|
+
resetRequestLog();
|
|
712
|
+
|
|
713
|
+
setupSyncServices(tmpFable, 'OngoingEventualConsistency',
|
|
714
|
+
(pError) =>
|
|
715
|
+
{
|
|
716
|
+
Expect(pError).to.not.exist;
|
|
717
|
+
tmpFable.MeadowSync.syncAll(
|
|
718
|
+
(pSyncError) =>
|
|
719
|
+
{
|
|
720
|
+
Expect(pSyncError).to.not.exist;
|
|
721
|
+
|
|
722
|
+
let tmpEntity = tmpFable.MeadowSync.MeadowSyncEntities['Book'];
|
|
723
|
+
|
|
724
|
+
// New tail records (50001-50500) should ALWAYS be pulled
|
|
725
|
+
// regardless of the time budget
|
|
726
|
+
Expect(tmpEntity.syncResults.Created).to.be.at.least(NEW_RECORDS_COUNT,
|
|
727
|
+
'All new tail records should be created regardless of time budget');
|
|
728
|
+
|
|
729
|
+
// Verify a new tail record exists
|
|
730
|
+
let tmpNewBook = getLocalBook(tmpFable, NEW_RECORDS_START + 10);
|
|
731
|
+
Expect(tmpNewBook).to.not.be.undefined;
|
|
732
|
+
Expect(tmpNewBook.Title).to.equal(`NewBook-${NEW_RECORDS_START + 10}`);
|
|
733
|
+
|
|
734
|
+
// With only 100ms budget, we should NOT have synced everything
|
|
735
|
+
// (the gap of 500 records + 200 scattered updates would take much longer)
|
|
736
|
+
// But some back-sync work should have been done
|
|
737
|
+
let tmpTotalSynced = tmpEntity.syncResults.Created + tmpEntity.syncResults.Updated;
|
|
738
|
+
Expect(tmpTotalSynced).to.be.at.least(NEW_RECORDS_COUNT,
|
|
739
|
+
'Should have synced at least the new records');
|
|
740
|
+
|
|
741
|
+
return fDone();
|
|
742
|
+
});
|
|
743
|
+
}, { BackSyncTimeLimit: 100 });
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
);
|
|
747
|
+
|
|
748
|
+
test
|
|
749
|
+
(
|
|
750
|
+
'Unlimited time budget — should fully sync all fragmentation',
|
|
751
|
+
function (fDone)
|
|
752
|
+
{
|
|
753
|
+
this.timeout(300000);
|
|
754
|
+
|
|
755
|
+
_MockServerData.Books = buildFragmentedServerData();
|
|
756
|
+
let tmpLocalBooks = buildLocalData();
|
|
757
|
+
|
|
758
|
+
let tmpFable = createTestFable();
|
|
759
|
+
setupSQLiteProvider(tmpFable,
|
|
760
|
+
(pError) =>
|
|
761
|
+
{
|
|
762
|
+
Expect(pError).to.not.exist;
|
|
763
|
+
seedLocalBooks(tmpFable, tmpLocalBooks);
|
|
764
|
+
|
|
765
|
+
resetRequestLog();
|
|
766
|
+
|
|
767
|
+
setupSyncServices(tmpFable, 'OngoingEventualConsistency',
|
|
768
|
+
(pError) =>
|
|
769
|
+
{
|
|
770
|
+
Expect(pError).to.not.exist;
|
|
771
|
+
tmpFable.MeadowSync.syncAll(
|
|
772
|
+
(pSyncError) =>
|
|
773
|
+
{
|
|
774
|
+
Expect(pSyncError).to.not.exist;
|
|
775
|
+
|
|
776
|
+
let tmpEntity = tmpFable.MeadowSync.MeadowSyncEntities['Book'];
|
|
777
|
+
|
|
778
|
+
// Gap records (20001-20500) should be created
|
|
779
|
+
Expect(tmpEntity.syncResults.Created).to.be.at.least(GAP_SIZE + NEW_RECORDS_COUNT,
|
|
780
|
+
`Should create at least ${GAP_SIZE} gap records + ${NEW_RECORDS_COUNT} new records`);
|
|
781
|
+
|
|
782
|
+
// Scattered updates should be applied
|
|
783
|
+
Expect(tmpEntity.syncResults.Updated).to.be.at.least(SCATTERED_UPDATE_COUNT,
|
|
784
|
+
`Should update at least ${SCATTERED_UPDATE_COUNT} scattered records`);
|
|
785
|
+
|
|
786
|
+
// Verify specific gap record
|
|
787
|
+
let tmpGapBook = getLocalBook(tmpFable, GAP_START + 50);
|
|
788
|
+
Expect(tmpGapBook).to.not.be.undefined;
|
|
789
|
+
Expect(tmpGapBook.Title).to.equal(`Book-${GAP_START + 50}`);
|
|
790
|
+
|
|
791
|
+
// Verify a scattered update (every 5000th ID)
|
|
792
|
+
let tmpScatteredBook = getLocalBook(tmpFable, 5000);
|
|
793
|
+
Expect(tmpScatteredBook.Title).to.equal('Scattered-5000');
|
|
794
|
+
|
|
795
|
+
// Verify new tail record
|
|
796
|
+
let tmpTailBook = getLocalBook(tmpFable, NEW_RECORDS_END);
|
|
797
|
+
Expect(tmpTailBook).to.not.be.undefined;
|
|
798
|
+
Expect(tmpTailBook.Title).to.equal(`NewBook-${NEW_RECORDS_END}`);
|
|
799
|
+
|
|
800
|
+
return fDone();
|
|
801
|
+
});
|
|
802
|
+
}, { BackSyncTimeLimit: 999999 });
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
);
|
|
806
|
+
|
|
807
|
+
test
|
|
808
|
+
(
|
|
809
|
+
'Backwards bisection should prioritize high IDs over low IDs',
|
|
810
|
+
function (fDone)
|
|
811
|
+
{
|
|
812
|
+
this.timeout(300000);
|
|
813
|
+
|
|
814
|
+
// Create a clean dataset with changes at both ends
|
|
815
|
+
let tmpServerBooks = generateBooks(RECORD_COUNT, BASE_UPDATE_DATE);
|
|
816
|
+
// Mutate 50 records near the END
|
|
817
|
+
mutateBooks(tmpServerBooks, 49900, 49950, NEWER_UPDATE_DATE, 'HighEnd');
|
|
818
|
+
// Mutate 50 records near the START
|
|
819
|
+
mutateBooks(tmpServerBooks, 100, 150, NEWER_UPDATE_DATE, 'LowEnd');
|
|
820
|
+
_MockServerData.Books = tmpServerBooks;
|
|
821
|
+
|
|
822
|
+
let tmpLocalBooks = generateBooks(RECORD_COUNT, BASE_UPDATE_DATE);
|
|
823
|
+
|
|
824
|
+
let tmpFable = createTestFable();
|
|
825
|
+
setupSQLiteProvider(tmpFable,
|
|
826
|
+
(pError) =>
|
|
827
|
+
{
|
|
828
|
+
Expect(pError).to.not.exist;
|
|
829
|
+
seedLocalBooks(tmpFable, tmpLocalBooks);
|
|
830
|
+
|
|
831
|
+
resetRequestLog();
|
|
832
|
+
|
|
833
|
+
setupSyncServices(tmpFable, 'OngoingEventualConsistency',
|
|
834
|
+
(pError) =>
|
|
835
|
+
{
|
|
836
|
+
Expect(pError).to.not.exist;
|
|
837
|
+
tmpFable.MeadowSync.syncAll(
|
|
838
|
+
(pSyncError) =>
|
|
839
|
+
{
|
|
840
|
+
Expect(pSyncError).to.not.exist;
|
|
841
|
+
|
|
842
|
+
let tmpEntity = tmpFable.MeadowSync.MeadowSyncEntities['Book'];
|
|
843
|
+
|
|
844
|
+
// With a very short budget, the high-end changes should
|
|
845
|
+
// be prioritized because backwards bisection starts from
|
|
846
|
+
// the upper half
|
|
847
|
+
let tmpHighEndSynced = getLocalBook(tmpFable, 49925);
|
|
848
|
+
Expect(tmpHighEndSynced.Title).to.equal('HighEnd-49925',
|
|
849
|
+
'High-end records should be prioritized by backwards bisection');
|
|
850
|
+
|
|
851
|
+
return fDone();
|
|
852
|
+
});
|
|
853
|
+
}, { BackSyncTimeLimit: 100 });
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
);
|
|
859
|
+
|
|
860
|
+
// ════════════════════════════════════════════════════════════════════
|
|
861
|
+
// TrueUp
|
|
862
|
+
// ════════════════════════════════════════════════════════════════════
|
|
863
|
+
|
|
864
|
+
suite
|
|
865
|
+
(
|
|
866
|
+
'TrueUp',
|
|
867
|
+
() =>
|
|
868
|
+
{
|
|
869
|
+
test
|
|
870
|
+
(
|
|
871
|
+
'Full true-up with mixed fragmentation — should sync all differences',
|
|
872
|
+
function (fDone)
|
|
873
|
+
{
|
|
874
|
+
this.timeout(300000);
|
|
875
|
+
|
|
876
|
+
_MockServerData.Books = buildFragmentedServerData();
|
|
877
|
+
let tmpLocalBooks = buildLocalData();
|
|
878
|
+
|
|
879
|
+
let tmpFable = createTestFable();
|
|
880
|
+
setupSQLiteProvider(tmpFable,
|
|
881
|
+
(pError) =>
|
|
882
|
+
{
|
|
883
|
+
Expect(pError).to.not.exist;
|
|
884
|
+
seedLocalBooks(tmpFable, tmpLocalBooks);
|
|
885
|
+
|
|
886
|
+
Expect(getLocalBookCount(tmpFable)).to.equal(RECORD_COUNT - GAP_SIZE);
|
|
887
|
+
|
|
888
|
+
resetRequestLog();
|
|
889
|
+
|
|
890
|
+
setupSyncServices(tmpFable, 'TrueUp',
|
|
891
|
+
(pError) =>
|
|
892
|
+
{
|
|
893
|
+
Expect(pError).to.not.exist;
|
|
894
|
+
tmpFable.MeadowSync.syncAll(
|
|
895
|
+
(pSyncError) =>
|
|
896
|
+
{
|
|
897
|
+
Expect(pSyncError).to.not.exist;
|
|
898
|
+
|
|
899
|
+
let tmpEntity = tmpFable.MeadowSync.MeadowSyncEntities['Book'];
|
|
900
|
+
|
|
901
|
+
// All gap records + new tail records should be created
|
|
902
|
+
Expect(tmpEntity.syncResults.Created).to.be.at.least(GAP_SIZE + NEW_RECORDS_COUNT,
|
|
903
|
+
`Should create at least ${GAP_SIZE + NEW_RECORDS_COUNT} records (gap + new)`);
|
|
904
|
+
|
|
905
|
+
// Scattered updates should be applied
|
|
906
|
+
Expect(tmpEntity.syncResults.Updated).to.be.at.least(SCATTERED_UPDATE_COUNT,
|
|
907
|
+
`Should update at least ${SCATTERED_UPDATE_COUNT} scattered records`);
|
|
908
|
+
|
|
909
|
+
// Final count: 50,000 original + 500 new = 50,500 active
|
|
910
|
+
let tmpFinalCount = getLocalBookCount(tmpFable);
|
|
911
|
+
Expect(tmpFinalCount).to.equal(RECORD_COUNT + NEW_RECORDS_COUNT);
|
|
912
|
+
|
|
913
|
+
// Verify gap was filled
|
|
914
|
+
let tmpGapBook = getLocalBook(tmpFable, GAP_START + 100);
|
|
915
|
+
Expect(tmpGapBook).to.not.be.undefined;
|
|
916
|
+
Expect(tmpGapBook.Title).to.equal(`Book-${GAP_START + 100}`);
|
|
917
|
+
|
|
918
|
+
// Verify scattered update applied (every 5000th ID)
|
|
919
|
+
let tmpScattered = getLocalBook(tmpFable, 10000);
|
|
920
|
+
Expect(tmpScattered.Title).to.equal('Scattered-10000');
|
|
921
|
+
|
|
922
|
+
// Verify new tail record
|
|
923
|
+
let tmpNewBook = getLocalBook(tmpFable, NEW_RECORDS_END - 5);
|
|
924
|
+
Expect(tmpNewBook).to.not.be.undefined;
|
|
925
|
+
|
|
926
|
+
return fDone();
|
|
927
|
+
});
|
|
928
|
+
}, { TrueUpPageSize: 500 });
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
);
|
|
932
|
+
|
|
933
|
+
test
|
|
934
|
+
(
|
|
935
|
+
'TrueUp with deleted records enabled',
|
|
936
|
+
function (fDone)
|
|
937
|
+
{
|
|
938
|
+
this.timeout(300000);
|
|
939
|
+
|
|
940
|
+
_MockServerData.Books = buildFragmentedServerData();
|
|
941
|
+
let tmpLocalBooks = buildLocalData();
|
|
942
|
+
|
|
943
|
+
let tmpFable = createTestFable();
|
|
944
|
+
setupSQLiteProvider(tmpFable,
|
|
945
|
+
(pError) =>
|
|
946
|
+
{
|
|
947
|
+
Expect(pError).to.not.exist;
|
|
948
|
+
seedLocalBooks(tmpFable, tmpLocalBooks);
|
|
949
|
+
|
|
950
|
+
resetRequestLog();
|
|
951
|
+
|
|
952
|
+
setupSyncServices(tmpFable, 'TrueUp',
|
|
953
|
+
(pError) =>
|
|
954
|
+
{
|
|
955
|
+
Expect(pError).to.not.exist;
|
|
956
|
+
tmpFable.MeadowSync.syncAll(
|
|
957
|
+
(pSyncError) =>
|
|
958
|
+
{
|
|
959
|
+
Expect(pSyncError).to.not.exist;
|
|
960
|
+
|
|
961
|
+
// Check that deleted records were synced
|
|
962
|
+
let tmpDeletedBook = getLocalBook(tmpFable, DELETED_START + 5);
|
|
963
|
+
Expect(tmpDeletedBook).to.not.be.undefined;
|
|
964
|
+
Expect(tmpDeletedBook.Deleted).to.equal(1);
|
|
965
|
+
Expect(tmpDeletedBook.Title).to.equal(`Deleted-Book-${DELETED_START + 5}`);
|
|
966
|
+
|
|
967
|
+
// Total including deleted
|
|
968
|
+
let tmpTotalAll = getLocalBookCountAll(tmpFable);
|
|
969
|
+
Expect(tmpTotalAll).to.be.at.least(RECORD_COUNT + NEW_RECORDS_COUNT + DELETED_COUNT);
|
|
970
|
+
|
|
971
|
+
return fDone();
|
|
972
|
+
});
|
|
973
|
+
}, { TrueUpPageSize: 500, SyncDeletedRecords: true });
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
);
|
|
977
|
+
|
|
978
|
+
test
|
|
979
|
+
(
|
|
980
|
+
'TrueUp idempotency — second run should create zero new records',
|
|
981
|
+
function (fDone)
|
|
982
|
+
{
|
|
983
|
+
this.timeout(600000);
|
|
984
|
+
|
|
985
|
+
// Use a simpler dataset for idempotency (avoid timing out)
|
|
986
|
+
let tmpServerBooks = generateBooks(RECORD_COUNT, BASE_UPDATE_DATE);
|
|
987
|
+
applyScatteredUpdates(tmpServerBooks);
|
|
988
|
+
_MockServerData.Books = tmpServerBooks;
|
|
989
|
+
|
|
990
|
+
let tmpLocalBooks = generateBooks(RECORD_COUNT, BASE_UPDATE_DATE);
|
|
991
|
+
|
|
992
|
+
let tmpFable = createTestFable();
|
|
993
|
+
setupSQLiteProvider(tmpFable,
|
|
994
|
+
(pError) =>
|
|
995
|
+
{
|
|
996
|
+
Expect(pError).to.not.exist;
|
|
997
|
+
seedLocalBooks(tmpFable, tmpLocalBooks);
|
|
998
|
+
|
|
999
|
+
// First true-up
|
|
1000
|
+
setupSyncServices(tmpFable, 'TrueUp',
|
|
1001
|
+
(pError) =>
|
|
1002
|
+
{
|
|
1003
|
+
Expect(pError).to.not.exist;
|
|
1004
|
+
tmpFable.MeadowSync.syncAll(
|
|
1005
|
+
(pSyncError) =>
|
|
1006
|
+
{
|
|
1007
|
+
Expect(pSyncError).to.not.exist;
|
|
1008
|
+
|
|
1009
|
+
let tmpEntity1 = tmpFable.MeadowSync.MeadowSyncEntities['Book'];
|
|
1010
|
+
Expect(tmpEntity1.syncResults.Updated).to.be.at.least(SCATTERED_UPDATE_COUNT);
|
|
1011
|
+
|
|
1012
|
+
// Second true-up — should create nothing new
|
|
1013
|
+
resetRequestLog();
|
|
1014
|
+
setupSyncServices(tmpFable, 'TrueUp',
|
|
1015
|
+
(pError2) =>
|
|
1016
|
+
{
|
|
1017
|
+
Expect(pError2).to.not.exist;
|
|
1018
|
+
tmpFable.MeadowSync.syncAll(
|
|
1019
|
+
(pSyncError2) =>
|
|
1020
|
+
{
|
|
1021
|
+
Expect(pSyncError2).to.not.exist;
|
|
1022
|
+
|
|
1023
|
+
let tmpEntity2 = tmpFable.MeadowSync.MeadowSyncEntities['Book'];
|
|
1024
|
+
Expect(tmpEntity2.syncResults.Created).to.equal(0,
|
|
1025
|
+
'Second true-up should create zero new records');
|
|
1026
|
+
|
|
1027
|
+
return fDone();
|
|
1028
|
+
});
|
|
1029
|
+
}, { TrueUpPageSize: 500 });
|
|
1030
|
+
});
|
|
1031
|
+
}, { TrueUpPageSize: 500 });
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
);
|
|
1037
|
+
|
|
1038
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1039
|
+
// ComparisonOnly
|
|
1040
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1041
|
+
|
|
1042
|
+
suite
|
|
1043
|
+
(
|
|
1044
|
+
'ComparisonOnly',
|
|
1045
|
+
() =>
|
|
1046
|
+
{
|
|
1047
|
+
test
|
|
1048
|
+
(
|
|
1049
|
+
'Comparison report accuracy with mixed fragmentation',
|
|
1050
|
+
function (fDone)
|
|
1051
|
+
{
|
|
1052
|
+
this.timeout(300000);
|
|
1053
|
+
|
|
1054
|
+
_MockServerData.Books = buildFragmentedServerData();
|
|
1055
|
+
let tmpLocalBooks = buildLocalData();
|
|
1056
|
+
|
|
1057
|
+
let tmpFable = createTestFable();
|
|
1058
|
+
setupSQLiteProvider(tmpFable,
|
|
1059
|
+
(pError) =>
|
|
1060
|
+
{
|
|
1061
|
+
Expect(pError).to.not.exist;
|
|
1062
|
+
seedLocalBooks(tmpFable, tmpLocalBooks);
|
|
1063
|
+
|
|
1064
|
+
resetRequestLog();
|
|
1065
|
+
|
|
1066
|
+
setupSyncServices(tmpFable, 'ComparisonOnly',
|
|
1067
|
+
(pError) =>
|
|
1068
|
+
{
|
|
1069
|
+
Expect(pError).to.not.exist;
|
|
1070
|
+
tmpFable.MeadowSync.syncAll(
|
|
1071
|
+
(pSyncError) =>
|
|
1072
|
+
{
|
|
1073
|
+
Expect(pSyncError).to.not.exist;
|
|
1074
|
+
|
|
1075
|
+
let tmpEntity = tmpFable.MeadowSync.MeadowSyncEntities['Book'];
|
|
1076
|
+
|
|
1077
|
+
// No records should have been synced
|
|
1078
|
+
Expect(tmpEntity.syncResults.Created).to.equal(0,
|
|
1079
|
+
'ComparisonOnly should not create any records');
|
|
1080
|
+
Expect(tmpEntity.syncResults.Updated).to.equal(0,
|
|
1081
|
+
'ComparisonOnly should not update any records');
|
|
1082
|
+
|
|
1083
|
+
// ComparisonReport should exist
|
|
1084
|
+
Expect(tmpEntity.ComparisonReport).to.be.an('object');
|
|
1085
|
+
let tmpReport = tmpEntity.ComparisonReport;
|
|
1086
|
+
|
|
1087
|
+
// Validate report structure
|
|
1088
|
+
Expect(tmpReport.Entity).to.equal('Book');
|
|
1089
|
+
Expect(tmpReport.Timestamp).to.be.a('string');
|
|
1090
|
+
Expect(tmpReport.Summary).to.be.an('object');
|
|
1091
|
+
Expect(tmpReport.Ranges).to.be.an('array');
|
|
1092
|
+
|
|
1093
|
+
// There should be mismatches (gap + new records + scattered updates)
|
|
1094
|
+
Expect(tmpReport.Summary.MismatchedRanges).to.be.above(0,
|
|
1095
|
+
'Should detect mismatched ranges from gap and scattered updates');
|
|
1096
|
+
|
|
1097
|
+
// There should also be matching ranges (unchanged regions)
|
|
1098
|
+
Expect(tmpReport.Summary.MatchingRanges).to.be.above(0,
|
|
1099
|
+
'Should detect matching ranges in unchanged regions');
|
|
1100
|
+
|
|
1101
|
+
// Range counts should add up
|
|
1102
|
+
Expect(tmpReport.Summary.TotalRangesChecked).to.equal(
|
|
1103
|
+
tmpReport.Summary.MatchingRanges +
|
|
1104
|
+
tmpReport.Summary.MismatchedRanges +
|
|
1105
|
+
tmpReport.Summary.ErrorRanges,
|
|
1106
|
+
'Total ranges should equal matching + mismatched + error');
|
|
1107
|
+
|
|
1108
|
+
// Local data should be untouched
|
|
1109
|
+
let tmpLocalCount = getLocalBookCount(tmpFable);
|
|
1110
|
+
Expect(tmpLocalCount).to.equal(RECORD_COUNT - GAP_SIZE,
|
|
1111
|
+
'Local record count should be unchanged after comparison');
|
|
1112
|
+
|
|
1113
|
+
return fDone();
|
|
1114
|
+
});
|
|
1115
|
+
});
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
);
|
|
1119
|
+
|
|
1120
|
+
test
|
|
1121
|
+
(
|
|
1122
|
+
'Comparison on identical data — should report zero mismatches',
|
|
1123
|
+
function (fDone)
|
|
1124
|
+
{
|
|
1125
|
+
this.timeout(300000);
|
|
1126
|
+
|
|
1127
|
+
let tmpBooks = generateBooks(RECORD_COUNT, BASE_UPDATE_DATE);
|
|
1128
|
+
_MockServerData.Books = tmpBooks.map((b) => Object.assign({}, b));
|
|
1129
|
+
|
|
1130
|
+
let tmpFable = createTestFable();
|
|
1131
|
+
setupSQLiteProvider(tmpFable,
|
|
1132
|
+
(pError) =>
|
|
1133
|
+
{
|
|
1134
|
+
Expect(pError).to.not.exist;
|
|
1135
|
+
seedLocalBooks(tmpFable, tmpBooks);
|
|
1136
|
+
|
|
1137
|
+
resetRequestLog();
|
|
1138
|
+
|
|
1139
|
+
setupSyncServices(tmpFable, 'ComparisonOnly',
|
|
1140
|
+
(pError) =>
|
|
1141
|
+
{
|
|
1142
|
+
Expect(pError).to.not.exist;
|
|
1143
|
+
tmpFable.MeadowSync.syncAll(
|
|
1144
|
+
(pSyncError) =>
|
|
1145
|
+
{
|
|
1146
|
+
Expect(pSyncError).to.not.exist;
|
|
1147
|
+
|
|
1148
|
+
let tmpEntity = tmpFable.MeadowSync.MeadowSyncEntities['Book'];
|
|
1149
|
+
let tmpReport = tmpEntity.ComparisonReport;
|
|
1150
|
+
|
|
1151
|
+
Expect(tmpReport.Summary.MismatchedRanges).to.equal(0,
|
|
1152
|
+
'Identical data should have zero mismatches');
|
|
1153
|
+
Expect(tmpReport.Summary.MatchingRanges).to.be.above(0,
|
|
1154
|
+
'Should have matching ranges');
|
|
1155
|
+
Expect(tmpEntity.syncResults.Created).to.equal(0);
|
|
1156
|
+
Expect(tmpEntity.syncResults.Updated).to.equal(0);
|
|
1157
|
+
|
|
1158
|
+
return fDone();
|
|
1159
|
+
});
|
|
1160
|
+
});
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
);
|
|
1164
|
+
|
|
1165
|
+
test
|
|
1166
|
+
(
|
|
1167
|
+
'Report contains UpdateDate mismatch details when counts match but dates differ',
|
|
1168
|
+
function (fDone)
|
|
1169
|
+
{
|
|
1170
|
+
this.timeout(300000);
|
|
1171
|
+
|
|
1172
|
+
let tmpBooks = generateBooks(RECORD_COUNT, BASE_UPDATE_DATE);
|
|
1173
|
+
_MockServerData.Books = tmpBooks.map((b) => Object.assign({}, b));
|
|
1174
|
+
|
|
1175
|
+
let tmpFable = createTestFable();
|
|
1176
|
+
setupSQLiteProvider(tmpFable,
|
|
1177
|
+
(pError) =>
|
|
1178
|
+
{
|
|
1179
|
+
Expect(pError).to.not.exist;
|
|
1180
|
+
seedLocalBooks(tmpFable, tmpBooks);
|
|
1181
|
+
|
|
1182
|
+
// Mutate 50 records on server (same count, different dates)
|
|
1183
|
+
mutateBooks(_MockServerData.Books, 5000, 5050, NEWER_UPDATE_DATE, 'DateChanged');
|
|
1184
|
+
|
|
1185
|
+
resetRequestLog();
|
|
1186
|
+
|
|
1187
|
+
setupSyncServices(tmpFable, 'ComparisonOnly',
|
|
1188
|
+
(pError) =>
|
|
1189
|
+
{
|
|
1190
|
+
Expect(pError).to.not.exist;
|
|
1191
|
+
tmpFable.MeadowSync.syncAll(
|
|
1192
|
+
(pSyncError) =>
|
|
1193
|
+
{
|
|
1194
|
+
Expect(pSyncError).to.not.exist;
|
|
1195
|
+
|
|
1196
|
+
let tmpEntity = tmpFable.MeadowSync.MeadowSyncEntities['Book'];
|
|
1197
|
+
let tmpReport = tmpEntity.ComparisonReport;
|
|
1198
|
+
|
|
1199
|
+
// Should detect date mismatches
|
|
1200
|
+
Expect(tmpReport.Summary.MismatchedRanges).to.be.above(0,
|
|
1201
|
+
'Should detect mismatches when UpdateDates differ');
|
|
1202
|
+
|
|
1203
|
+
// Find a mismatch range and verify it has UpdateDate details
|
|
1204
|
+
let tmpDateMismatch = tmpReport.Ranges.find(
|
|
1205
|
+
(r) => r.Status === 'mismatch' && r.UpdateDateDifferenceMS > 0);
|
|
1206
|
+
Expect(tmpDateMismatch).to.not.be.undefined;
|
|
1207
|
+
Expect(tmpDateMismatch.UpdateDateDifferenceMS).to.be.above(0);
|
|
1208
|
+
Expect(tmpDateMismatch.LocalMaxUpdateDate).to.be.a('string');
|
|
1209
|
+
Expect(tmpDateMismatch.ServerMaxUpdateDate).to.be.a('string');
|
|
1210
|
+
|
|
1211
|
+
// No records should be synced
|
|
1212
|
+
Expect(tmpEntity.syncResults.Created).to.equal(0);
|
|
1213
|
+
Expect(tmpEntity.syncResults.Updated).to.equal(0);
|
|
1214
|
+
|
|
1215
|
+
return fDone();
|
|
1216
|
+
});
|
|
1217
|
+
});
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
);
|
|
1221
|
+
|
|
1222
|
+
test
|
|
1223
|
+
(
|
|
1224
|
+
'Report stored on syncResults.ComparisonReport',
|
|
1225
|
+
function (fDone)
|
|
1226
|
+
{
|
|
1227
|
+
this.timeout(300000);
|
|
1228
|
+
|
|
1229
|
+
let tmpBooks = generateBooks(5000, BASE_UPDATE_DATE);
|
|
1230
|
+
_MockServerData.Books = tmpBooks.map((b) => Object.assign({}, b));
|
|
1231
|
+
|
|
1232
|
+
let tmpFable = createTestFable();
|
|
1233
|
+
setupSQLiteProvider(tmpFable,
|
|
1234
|
+
(pError) =>
|
|
1235
|
+
{
|
|
1236
|
+
Expect(pError).to.not.exist;
|
|
1237
|
+
seedLocalBooks(tmpFable, tmpBooks);
|
|
1238
|
+
|
|
1239
|
+
setupSyncServices(tmpFable, 'ComparisonOnly',
|
|
1240
|
+
(pError) =>
|
|
1241
|
+
{
|
|
1242
|
+
Expect(pError).to.not.exist;
|
|
1243
|
+
tmpFable.MeadowSync.syncAll(
|
|
1244
|
+
(pSyncError) =>
|
|
1245
|
+
{
|
|
1246
|
+
Expect(pSyncError).to.not.exist;
|
|
1247
|
+
|
|
1248
|
+
let tmpEntity = tmpFable.MeadowSync.MeadowSyncEntities['Book'];
|
|
1249
|
+
|
|
1250
|
+
// syncResults.ComparisonReport should reference the same report
|
|
1251
|
+
Expect(tmpEntity.syncResults.ComparisonReport).to.equal(tmpEntity.ComparisonReport,
|
|
1252
|
+
'syncResults.ComparisonReport should reference the same report object');
|
|
1253
|
+
Expect(tmpEntity.syncResults.ComparisonReport.Entity).to.equal('Book');
|
|
1254
|
+
Expect(tmpEntity.syncResults.ComparisonReport.Summary).to.be.an('object');
|
|
1255
|
+
|
|
1256
|
+
return fDone();
|
|
1257
|
+
});
|
|
1258
|
+
});
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
);
|
|
1262
|
+
}
|
|
1263
|
+
);
|
|
1264
|
+
}
|
|
1265
|
+
);
|