meadow-integration 1.0.19 → 1.0.21
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/example-applications/mapping-demo/.quackage.json +10 -0
- package/example-applications/mapping-demo/README.md +99 -0
- package/example-applications/mapping-demo/data/books-sample.csv +21 -0
- package/example-applications/mapping-demo/generate-build-config.js +44 -0
- package/example-applications/mapping-demo/mappings/books-to-book.json +14 -0
- package/example-applications/mapping-demo/package.json +14 -0
- package/example-applications/mapping-demo/server.js +814 -0
- package/example-applications/mapping-demo/source/MappingDemoApp.js +52 -0
- package/example-applications/mapping-demo/source/views/MappingDemoEditorView.js +186 -0
- package/example-applications/mapping-demo/web/index.html +892 -0
- package/example-applications/mapping-demo/web/mapping-demo-editor.js +3195 -0
- package/example-applications/mapping-demo/web/mapping-demo-editor.js.map +1 -0
- package/example-applications/mapping-demo/web/mapping-demo-editor.min.js +2 -0
- package/example-applications/mapping-demo/web/mapping-demo-editor.min.js.map +1 -0
- package/example-applications/mapping-demo/web/pict.min.js +12 -0
- package/package.json +11 -5
- package/source/Meadow-Integration-Browser.js +31 -0
- package/source/Meadow-Integration.js +30 -1
- package/source/services/certainty/Service-CertaintyAccumulator.js +402 -0
- package/source/services/clone/Meadow-Service-Sync-Entity-Initial.js +16 -3
- package/source/services/clone/Meadow-Service-Sync-Entity-Ongoing.js +15 -2
- package/source/services/clone/Meadow-Service-Sync.js +21 -0
- package/source/services/parser/Service-FileParser-CSV.js +263 -0
- package/source/services/parser/Service-FileParser-FixedWidth.js +158 -0
- package/source/services/parser/Service-FileParser-JSON.js +255 -0
- package/source/services/parser/Service-FileParser-XLSX.js +194 -0
- package/source/services/parser/Service-FileParser-XML.js +190 -0
- package/source/services/parser/Service-FileParser.js +142 -0
- package/source/views/MappingEditor-SchemaUtils.js +71 -0
- package/source/views/PictView-MeadowMappingEditor.js +1299 -0
- package/source/views/flow-cards/FlowCard-MappingSource.js +50 -0
- package/source/views/flow-cards/FlowCard-MappingTarget.js +49 -0
- package/source/views/flow-cards/FlowCard-SolverExpression.js +78 -0
- package/source/views/flow-cards/FlowCard-TemplateExpression.js +77 -0
- package/test/Meadow-Integration-CloneDeleteSync_test.js +809 -0
|
@@ -0,0 +1,809 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Unit tests for Clone Delete Sync
|
|
3
|
+
|
|
4
|
+
Validates that the clone sync correctly synchronizes deleted records
|
|
5
|
+
(Deleted=1) from a source API to the local database.
|
|
6
|
+
|
|
7
|
+
Uses a mock HTTP server to simulate meadow-endpoints API responses
|
|
8
|
+
and an in-memory SQLite database as the local clone destination.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const Chai = require('chai');
|
|
12
|
+
const Expect = Chai.expect;
|
|
13
|
+
|
|
14
|
+
const libHTTP = require('http');
|
|
15
|
+
const libFable = require('fable');
|
|
16
|
+
const libMeadow = require('meadow');
|
|
17
|
+
const libMeadowConnectionSQLite = require('meadow-connection-sqlite');
|
|
18
|
+
|
|
19
|
+
const libMeadowCloneRestClient = require('../source/services/clone/Meadow-Service-RestClient.js');
|
|
20
|
+
const libMeadowSync = require('../source/services/clone/Meadow-Service-Sync.js');
|
|
21
|
+
const libMeadowSyncEntityOngoing = require('../source/services/clone/Meadow-Service-Sync-Entity-Ongoing.js');
|
|
22
|
+
|
|
23
|
+
// ── Test Constants ──────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
const MOCK_PORT = 18099;
|
|
26
|
+
const MOCK_BASE_URL = `http://localhost:${MOCK_PORT}/1.0/`;
|
|
27
|
+
|
|
28
|
+
// ── Book Entity Schema (Extended Format) ────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
const _BookExtendedSchema =
|
|
31
|
+
{
|
|
32
|
+
Tables:
|
|
33
|
+
{
|
|
34
|
+
Book:
|
|
35
|
+
{
|
|
36
|
+
TableName: 'Book',
|
|
37
|
+
Columns:
|
|
38
|
+
[
|
|
39
|
+
{ Column: 'IDBook', DataType: 'int' },
|
|
40
|
+
{ Column: 'GUIDBook', DataType: 'GUID' },
|
|
41
|
+
{ Column: 'CreateDate', DataType: 'DateTime' },
|
|
42
|
+
{ Column: 'CreatingIDUser', DataType: 'int' },
|
|
43
|
+
{ Column: 'UpdateDate', DataType: 'DateTime' },
|
|
44
|
+
{ Column: 'UpdatingIDUser', DataType: 'int' },
|
|
45
|
+
{ Column: 'Deleted', DataType: 'int' },
|
|
46
|
+
{ Column: 'DeleteDate', DataType: 'DateTime' },
|
|
47
|
+
{ Column: 'DeletingIDUser', DataType: 'int' },
|
|
48
|
+
{ Column: 'Title', DataType: 'String' },
|
|
49
|
+
{ Column: 'Type', DataType: 'String' },
|
|
50
|
+
{ Column: 'Genre', DataType: 'String' },
|
|
51
|
+
{ Column: 'PublicationYear', DataType: 'int' }
|
|
52
|
+
],
|
|
53
|
+
MeadowSchema:
|
|
54
|
+
{
|
|
55
|
+
Scope: 'Book',
|
|
56
|
+
DefaultIdentifier: 'IDBook',
|
|
57
|
+
Domain: 'Default',
|
|
58
|
+
Schema:
|
|
59
|
+
[
|
|
60
|
+
{ Column: 'IDBook', Type: 'AutoIdentity', Size: 'Default' },
|
|
61
|
+
{ Column: 'GUIDBook', Type: 'AutoGUID', Size: '128' },
|
|
62
|
+
{ Column: 'CreateDate', Type: 'CreateDate', Size: 'Default' },
|
|
63
|
+
{ Column: 'CreatingIDUser', Type: 'CreateIDUser', Size: 'int' },
|
|
64
|
+
{ Column: 'UpdateDate', Type: 'UpdateDate', Size: 'Default' },
|
|
65
|
+
{ Column: 'UpdatingIDUser', Type: 'UpdateIDUser', Size: 'int' },
|
|
66
|
+
{ Column: 'Deleted', Type: 'Deleted', Size: 'Default' },
|
|
67
|
+
{ Column: 'DeleteDate', Type: 'DeleteDate', Size: 'Default' },
|
|
68
|
+
{ Column: 'DeletingIDUser', Type: 'DeleteIDUser', Size: 'int' },
|
|
69
|
+
{ Column: 'Title', Type: 'String', Size: '200' },
|
|
70
|
+
{ Column: 'Type', Type: 'String', Size: '32' },
|
|
71
|
+
{ Column: 'Genre', Type: 'String', Size: '128' },
|
|
72
|
+
{ Column: 'PublicationYear', Type: 'Integer', Size: 'int' }
|
|
73
|
+
],
|
|
74
|
+
DefaultObject:
|
|
75
|
+
{
|
|
76
|
+
IDBook: 0, GUIDBook: '', CreateDate: null, CreatingIDUser: 0,
|
|
77
|
+
UpdateDate: null, UpdatingIDUser: 0, Deleted: 0,
|
|
78
|
+
DeleteDate: null, DeletingIDUser: 0,
|
|
79
|
+
Title: '', Type: '', Genre: '', PublicationYear: 0
|
|
80
|
+
},
|
|
81
|
+
JsonSchema:
|
|
82
|
+
{
|
|
83
|
+
title: 'Book',
|
|
84
|
+
type: 'object',
|
|
85
|
+
properties:
|
|
86
|
+
{
|
|
87
|
+
IDBook: { type: 'integer' },
|
|
88
|
+
GUIDBook: { type: 'string' },
|
|
89
|
+
CreateDate: { type: 'string' },
|
|
90
|
+
CreatingIDUser: { type: 'integer' },
|
|
91
|
+
UpdateDate: { type: 'string' },
|
|
92
|
+
UpdatingIDUser: { type: 'integer' },
|
|
93
|
+
Deleted: { type: 'boolean' },
|
|
94
|
+
DeleteDate: { type: 'string' },
|
|
95
|
+
DeletingIDUser: { type: 'integer' },
|
|
96
|
+
Title: { type: 'string' },
|
|
97
|
+
Type: { type: 'string' },
|
|
98
|
+
Genre: { type: 'string' },
|
|
99
|
+
PublicationYear: { type: 'integer' }
|
|
100
|
+
},
|
|
101
|
+
required: ['IDBook']
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// ── Test Data ───────────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
function makeBookRecord(pID, pTitle, pGenre, pDeleted)
|
|
111
|
+
{
|
|
112
|
+
return {
|
|
113
|
+
IDBook: pID,
|
|
114
|
+
GUIDBook: `GUID-BOOK-${pID}`,
|
|
115
|
+
CreateDate: '2025-01-01T00:00:00.000Z',
|
|
116
|
+
CreatingIDUser: 1,
|
|
117
|
+
UpdateDate: '2025-06-15T12:00:00.000Z',
|
|
118
|
+
UpdatingIDUser: 1,
|
|
119
|
+
Deleted: pDeleted ? 1 : 0,
|
|
120
|
+
DeleteDate: pDeleted ? '2025-07-01T00:00:00.000Z' : '',
|
|
121
|
+
DeletingIDUser: pDeleted ? 1 : 0,
|
|
122
|
+
Title: pTitle,
|
|
123
|
+
Type: 'Fiction',
|
|
124
|
+
Genre: pGenre,
|
|
125
|
+
PublicationYear: 2020 + pID
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 5 active records, 3 deleted records
|
|
130
|
+
const ACTIVE_BOOKS =
|
|
131
|
+
[
|
|
132
|
+
makeBookRecord(1, 'The Great Adventure', 'Adventure', false),
|
|
133
|
+
makeBookRecord(2, 'Mystery at Midnight', 'Mystery', false),
|
|
134
|
+
makeBookRecord(3, 'Science Frontiers', 'Science', false),
|
|
135
|
+
makeBookRecord(4, 'Love in Paris', 'Romance', false),
|
|
136
|
+
makeBookRecord(5, 'Code Warriors', 'Technology', false)
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
const DELETED_BOOKS =
|
|
140
|
+
[
|
|
141
|
+
makeBookRecord(6, 'Forgotten Tales', 'Fantasy', true),
|
|
142
|
+
makeBookRecord(7, 'Lost Horizons', 'Travel', true),
|
|
143
|
+
makeBookRecord(8, 'Discontinued Edition', 'Reference', true)
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
const ALL_BOOKS = ACTIVE_BOOKS.concat(DELETED_BOOKS);
|
|
147
|
+
|
|
148
|
+
// ── Mock HTTP Server ────────────────────────────────────────────────────────────
|
|
149
|
+
// Simulates meadow-endpoints API responses for the Book entity.
|
|
150
|
+
|
|
151
|
+
let _MockServerData =
|
|
152
|
+
{
|
|
153
|
+
ActiveBooks: ACTIVE_BOOKS,
|
|
154
|
+
DeletedBooks: DELETED_BOOKS,
|
|
155
|
+
// When true, simulate old API: FBV~Deleted~EQ~1 returns 0 unless ?includeDeleted=true is present
|
|
156
|
+
SimulateOldAPI: false
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
function createMockServer()
|
|
160
|
+
{
|
|
161
|
+
return libHTTP.createServer(
|
|
162
|
+
(pRequest, pResponse) =>
|
|
163
|
+
{
|
|
164
|
+
let tmpURL = pRequest.url;
|
|
165
|
+
let tmpBody = '';
|
|
166
|
+
|
|
167
|
+
pResponse.setHeader('Content-Type', 'application/json');
|
|
168
|
+
|
|
169
|
+
// GET /1.0/Book/Max/IDBook
|
|
170
|
+
if (tmpURL.match(/\/1\.0\/Book\/Max\/IDBook/))
|
|
171
|
+
{
|
|
172
|
+
let tmpAllBooks = _MockServerData.ActiveBooks.concat(_MockServerData.DeletedBooks);
|
|
173
|
+
let tmpMaxID = 0;
|
|
174
|
+
for (let i = 0; i < tmpAllBooks.length; i++)
|
|
175
|
+
{
|
|
176
|
+
if (tmpAllBooks[i].IDBook > tmpMaxID)
|
|
177
|
+
{
|
|
178
|
+
tmpMaxID = tmpAllBooks[i].IDBook;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
pResponse.end(JSON.stringify({ IDBook: tmpMaxID }));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// GET /1.0/Book/Max/UpdateDate
|
|
186
|
+
if (tmpURL.match(/\/1\.0\/Book\/Max\/UpdateDate/))
|
|
187
|
+
{
|
|
188
|
+
pResponse.end(JSON.stringify({ UpdateDate: '2025-06-15T12:00:00.000Z' }));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// GET /1.0/Books/Count/FilteredTo/FBV~Deleted~EQ~1
|
|
193
|
+
if (tmpURL.match(/\/1\.0\/Books\/Count\/FilteredTo\/FBV~Deleted~EQ~1/))
|
|
194
|
+
{
|
|
195
|
+
// Simulate old API: return 0 unless ?includeDeleted=true is present
|
|
196
|
+
if (_MockServerData.SimulateOldAPI && tmpURL.indexOf('includeDeleted=true') < 0)
|
|
197
|
+
{
|
|
198
|
+
pResponse.end(JSON.stringify({ Count: 0 }));
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
pResponse.end(JSON.stringify({ Count: _MockServerData.DeletedBooks.length }));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// GET /1.0/Books/Count/FilteredTo/<other filter>
|
|
206
|
+
if (tmpURL.match(/\/1\.0\/Books\/Count\/FilteredTo\//))
|
|
207
|
+
{
|
|
208
|
+
// For UpdateDate-based filters, return the count of all active records
|
|
209
|
+
pResponse.end(JSON.stringify({ Count: _MockServerData.ActiveBooks.length }));
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// GET /1.0/Books/Count
|
|
214
|
+
if (tmpURL.match(/\/1\.0\/Books\/Count$/))
|
|
215
|
+
{
|
|
216
|
+
pResponse.end(JSON.stringify({ Count: _MockServerData.ActiveBooks.length }));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// GET /1.0/Books/FilteredTo/FBV~Deleted~EQ~1~FSF~IDBook~ASC~ASC/{offset}/{pageSize}
|
|
221
|
+
if (tmpURL.match(/\/1\.0\/Books\/FilteredTo\/FBV~Deleted~EQ~1~FSF~IDBook~ASC~ASC/))
|
|
222
|
+
{
|
|
223
|
+
// Simulate old API: return empty unless ?includeDeleted=true is present
|
|
224
|
+
if (_MockServerData.SimulateOldAPI && tmpURL.indexOf('includeDeleted=true') < 0)
|
|
225
|
+
{
|
|
226
|
+
pResponse.end(JSON.stringify([]));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
let tmpParts = tmpURL.split('?')[0].split('/');
|
|
230
|
+
let tmpOffset = parseInt(tmpParts[tmpParts.length - 2]) || 0;
|
|
231
|
+
let tmpPageSize = parseInt(tmpParts[tmpParts.length - 1]) || 100;
|
|
232
|
+
let tmpPage = _MockServerData.DeletedBooks.slice(tmpOffset, tmpOffset + tmpPageSize);
|
|
233
|
+
pResponse.end(JSON.stringify(tmpPage));
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// GET /1.0/Books/FilteredTo/FBV~IDBook~GT~{id}~FSF~IDBook~ASC~ASC/{offset}/{pageSize}
|
|
238
|
+
// Also handles FBV~UpdateDate~GT~ filter patterns
|
|
239
|
+
if (tmpURL.match(/\/1\.0\/Books\/FilteredTo\//))
|
|
240
|
+
{
|
|
241
|
+
let tmpParts = tmpURL.split('/');
|
|
242
|
+
let tmpOffset = parseInt(tmpParts[tmpParts.length - 2]) || 0;
|
|
243
|
+
let tmpPageSize = parseInt(tmpParts[tmpParts.length - 1]) || 100;
|
|
244
|
+
|
|
245
|
+
// Extract the filter to determine what records to return
|
|
246
|
+
let tmpFilter = tmpParts[4] || '';
|
|
247
|
+
|
|
248
|
+
// Check if it's an ID-based filter
|
|
249
|
+
let tmpIDMatch = tmpFilter.match(/FBV~IDBook~GT~(\d+)/);
|
|
250
|
+
let tmpFilteredBooks;
|
|
251
|
+
|
|
252
|
+
if (tmpIDMatch)
|
|
253
|
+
{
|
|
254
|
+
let tmpMinID = parseInt(tmpIDMatch[1]);
|
|
255
|
+
tmpFilteredBooks = _MockServerData.ActiveBooks.filter(
|
|
256
|
+
(pBook) => { return pBook.IDBook > tmpMinID; });
|
|
257
|
+
}
|
|
258
|
+
else
|
|
259
|
+
{
|
|
260
|
+
// Default: return active books (e.g. for UpdateDate filters)
|
|
261
|
+
tmpFilteredBooks = _MockServerData.ActiveBooks;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
let tmpPage = tmpFilteredBooks.slice(tmpOffset, tmpOffset + tmpPageSize);
|
|
265
|
+
pResponse.end(JSON.stringify(tmpPage));
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Fallback — 404
|
|
270
|
+
pResponse.statusCode = 404;
|
|
271
|
+
pResponse.end(JSON.stringify({ Error: `Unknown endpoint: ${tmpURL}` }));
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ── Test Helpers ────────────────────────────────────────────────────────────────
|
|
276
|
+
|
|
277
|
+
function createTestFable()
|
|
278
|
+
{
|
|
279
|
+
let tmpFable = new libFable(
|
|
280
|
+
{
|
|
281
|
+
Product: 'CloneDeleteSyncTest',
|
|
282
|
+
ProductVersion: '1.0.0',
|
|
283
|
+
MeadowProvider: 'SQLite',
|
|
284
|
+
SQLite: { SQLiteFilePath: ':memory:' },
|
|
285
|
+
LogStreams: [{ streamtype: 'console', level: 'error' }]
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// MeadowSync expects ProgramConfiguration to exist (normally set by CLI utility)
|
|
289
|
+
tmpFable.ProgramConfiguration = {};
|
|
290
|
+
|
|
291
|
+
return tmpFable;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function setupSQLiteProvider(pFable, fCallback)
|
|
295
|
+
{
|
|
296
|
+
pFable.serviceManager.addServiceType('MeadowSQLiteProvider', libMeadowConnectionSQLite);
|
|
297
|
+
pFable.serviceManager.instantiateServiceProvider('MeadowSQLiteProvider');
|
|
298
|
+
pFable.MeadowSQLiteProvider.connectAsync(
|
|
299
|
+
(pError) =>
|
|
300
|
+
{
|
|
301
|
+
if (pError)
|
|
302
|
+
{
|
|
303
|
+
return fCallback(pError);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Create the Book table manually so the sync has somewhere to write
|
|
307
|
+
pFable.MeadowSQLiteProvider.db.exec(`
|
|
308
|
+
CREATE TABLE IF NOT EXISTS Book (
|
|
309
|
+
IDBook INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
310
|
+
GUIDBook TEXT DEFAULT '',
|
|
311
|
+
CreateDate TEXT DEFAULT '',
|
|
312
|
+
CreatingIDUser INTEGER DEFAULT 0,
|
|
313
|
+
UpdateDate TEXT DEFAULT '',
|
|
314
|
+
UpdatingIDUser INTEGER DEFAULT 0,
|
|
315
|
+
Deleted INTEGER DEFAULT 0,
|
|
316
|
+
DeleteDate TEXT DEFAULT '',
|
|
317
|
+
DeletingIDUser INTEGER DEFAULT 0,
|
|
318
|
+
Title TEXT DEFAULT '',
|
|
319
|
+
Type TEXT DEFAULT '',
|
|
320
|
+
Genre TEXT DEFAULT '',
|
|
321
|
+
PublicationYear INTEGER DEFAULT 0
|
|
322
|
+
);
|
|
323
|
+
`);
|
|
324
|
+
|
|
325
|
+
return fCallback();
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function setupSyncServices(pFable, pSyncMode, pSyncDeletedRecords, fCallback, pSyncEntityOptions)
|
|
330
|
+
{
|
|
331
|
+
pFable.serviceManager.addServiceType('MeadowCloneRestClient', libMeadowCloneRestClient);
|
|
332
|
+
pFable.serviceManager.instantiateServiceProvider('MeadowCloneRestClient',
|
|
333
|
+
{
|
|
334
|
+
ServerURL: MOCK_BASE_URL
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
let tmpSyncOptions =
|
|
338
|
+
{
|
|
339
|
+
PageSize: 100,
|
|
340
|
+
SyncDeletedRecords: pSyncDeletedRecords
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
if (pSyncEntityOptions)
|
|
344
|
+
{
|
|
345
|
+
tmpSyncOptions.SyncEntityOptions = pSyncEntityOptions;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
pFable.serviceManager.addServiceType('MeadowSync', libMeadowSync);
|
|
349
|
+
pFable.serviceManager.instantiateServiceProvider('MeadowSync', tmpSyncOptions);
|
|
350
|
+
|
|
351
|
+
pFable.MeadowSync.SyncMode = pSyncMode;
|
|
352
|
+
|
|
353
|
+
pFable.MeadowSync.loadMeadowSchema(_BookExtendedSchema,
|
|
354
|
+
(pError) =>
|
|
355
|
+
{
|
|
356
|
+
return fCallback(pError);
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function getLocalBooks(pFable)
|
|
361
|
+
{
|
|
362
|
+
return pFable.MeadowSQLiteProvider.db
|
|
363
|
+
.prepare('SELECT * FROM Book ORDER BY IDBook')
|
|
364
|
+
.all();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function getLocalDeletedBooks(pFable)
|
|
368
|
+
{
|
|
369
|
+
return pFable.MeadowSQLiteProvider.db
|
|
370
|
+
.prepare('SELECT * FROM Book WHERE Deleted = 1 ORDER BY IDBook')
|
|
371
|
+
.all();
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function getLocalActiveBooks(pFable)
|
|
375
|
+
{
|
|
376
|
+
return pFable.MeadowSQLiteProvider.db
|
|
377
|
+
.prepare('SELECT * FROM Book WHERE Deleted = 0 ORDER BY IDBook')
|
|
378
|
+
.all();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ── Test Suite ──────────────────────────────────────────────────────────────────
|
|
382
|
+
|
|
383
|
+
suite
|
|
384
|
+
(
|
|
385
|
+
'Clone Delete Sync',
|
|
386
|
+
() =>
|
|
387
|
+
{
|
|
388
|
+
let _MockServer = null;
|
|
389
|
+
|
|
390
|
+
suiteSetup
|
|
391
|
+
(
|
|
392
|
+
(fDone) =>
|
|
393
|
+
{
|
|
394
|
+
_MockServer = createMockServer();
|
|
395
|
+
_MockServer.listen(MOCK_PORT,
|
|
396
|
+
() =>
|
|
397
|
+
{
|
|
398
|
+
return fDone();
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
suiteTeardown
|
|
404
|
+
(
|
|
405
|
+
(fDone) =>
|
|
406
|
+
{
|
|
407
|
+
if (_MockServer)
|
|
408
|
+
{
|
|
409
|
+
_MockServer.close(fDone);
|
|
410
|
+
}
|
|
411
|
+
else
|
|
412
|
+
{
|
|
413
|
+
return fDone();
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
// ── Initial Sync with SyncDeletedRecords=true ───────────────────────
|
|
419
|
+
|
|
420
|
+
suite
|
|
421
|
+
(
|
|
422
|
+
'Initial Sync with SyncDeletedRecords=true',
|
|
423
|
+
() =>
|
|
424
|
+
{
|
|
425
|
+
let _Fable = null;
|
|
426
|
+
|
|
427
|
+
setup
|
|
428
|
+
(
|
|
429
|
+
(fDone) =>
|
|
430
|
+
{
|
|
431
|
+
// Reset mock data to full set
|
|
432
|
+
_MockServerData.ActiveBooks = ACTIVE_BOOKS.slice();
|
|
433
|
+
_MockServerData.DeletedBooks = DELETED_BOOKS.slice();
|
|
434
|
+
|
|
435
|
+
_Fable = createTestFable();
|
|
436
|
+
setupSQLiteProvider(_Fable,
|
|
437
|
+
(pError) =>
|
|
438
|
+
{
|
|
439
|
+
if (pError) return fDone(pError);
|
|
440
|
+
setupSyncServices(_Fable, 'Initial', true, fDone);
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
test
|
|
446
|
+
(
|
|
447
|
+
'should sync active and deleted records',
|
|
448
|
+
(fDone) =>
|
|
449
|
+
{
|
|
450
|
+
_Fable.MeadowSync.syncAll(
|
|
451
|
+
(pError) =>
|
|
452
|
+
{
|
|
453
|
+
Expect(pError).to.not.exist;
|
|
454
|
+
|
|
455
|
+
let tmpAllLocal = getLocalBooks(_Fable);
|
|
456
|
+
let tmpDeletedLocal = getLocalDeletedBooks(_Fable);
|
|
457
|
+
let tmpActiveLocal = getLocalActiveBooks(_Fable);
|
|
458
|
+
|
|
459
|
+
Expect(tmpAllLocal.length).to.equal(8,
|
|
460
|
+
`Expected 8 total records, got ${tmpAllLocal.length}`);
|
|
461
|
+
Expect(tmpActiveLocal.length).to.equal(5,
|
|
462
|
+
`Expected 5 active records, got ${tmpActiveLocal.length}`);
|
|
463
|
+
Expect(tmpDeletedLocal.length).to.equal(3,
|
|
464
|
+
`Expected 3 deleted records, got ${tmpDeletedLocal.length}`);
|
|
465
|
+
|
|
466
|
+
// Verify deleted record IDs
|
|
467
|
+
let tmpDeletedIDs = tmpDeletedLocal.map((r) => r.IDBook);
|
|
468
|
+
Expect(tmpDeletedIDs).to.include(6);
|
|
469
|
+
Expect(tmpDeletedIDs).to.include(7);
|
|
470
|
+
Expect(tmpDeletedIDs).to.include(8);
|
|
471
|
+
|
|
472
|
+
// Verify deleted records have correct data
|
|
473
|
+
let tmpBook6 = tmpDeletedLocal.find((r) => r.IDBook === 6);
|
|
474
|
+
Expect(tmpBook6.Title).to.equal('Forgotten Tales');
|
|
475
|
+
Expect(tmpBook6.Deleted).to.equal(1);
|
|
476
|
+
|
|
477
|
+
return fDone();
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
// ── Initial Sync with SyncDeletedRecords=false ──────────────────────
|
|
485
|
+
|
|
486
|
+
suite
|
|
487
|
+
(
|
|
488
|
+
'Initial Sync with SyncDeletedRecords=false',
|
|
489
|
+
() =>
|
|
490
|
+
{
|
|
491
|
+
let _Fable = null;
|
|
492
|
+
|
|
493
|
+
setup
|
|
494
|
+
(
|
|
495
|
+
(fDone) =>
|
|
496
|
+
{
|
|
497
|
+
_MockServerData.ActiveBooks = ACTIVE_BOOKS.slice();
|
|
498
|
+
_MockServerData.DeletedBooks = DELETED_BOOKS.slice();
|
|
499
|
+
|
|
500
|
+
_Fable = createTestFable();
|
|
501
|
+
setupSQLiteProvider(_Fable,
|
|
502
|
+
(pError) =>
|
|
503
|
+
{
|
|
504
|
+
if (pError) return fDone(pError);
|
|
505
|
+
setupSyncServices(_Fable, 'Initial', false, fDone);
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
test
|
|
511
|
+
(
|
|
512
|
+
'should only sync active records when SyncDeletedRecords is false',
|
|
513
|
+
(fDone) =>
|
|
514
|
+
{
|
|
515
|
+
_Fable.MeadowSync.syncAll(
|
|
516
|
+
(pError) =>
|
|
517
|
+
{
|
|
518
|
+
Expect(pError).to.not.exist;
|
|
519
|
+
|
|
520
|
+
let tmpAllLocal = getLocalBooks(_Fable);
|
|
521
|
+
let tmpDeletedLocal = getLocalDeletedBooks(_Fable);
|
|
522
|
+
|
|
523
|
+
Expect(tmpAllLocal.length).to.equal(5,
|
|
524
|
+
`Expected 5 total records (no deleted), got ${tmpAllLocal.length}`);
|
|
525
|
+
Expect(tmpDeletedLocal.length).to.equal(0,
|
|
526
|
+
`Expected 0 deleted records, got ${tmpDeletedLocal.length}`);
|
|
527
|
+
|
|
528
|
+
return fDone();
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
// ── Ongoing Sync with SyncDeletedRecords=true ───────────────────────
|
|
536
|
+
|
|
537
|
+
suite
|
|
538
|
+
(
|
|
539
|
+
'Ongoing Sync with SyncDeletedRecords=true',
|
|
540
|
+
() =>
|
|
541
|
+
{
|
|
542
|
+
let _Fable = null;
|
|
543
|
+
|
|
544
|
+
setup
|
|
545
|
+
(
|
|
546
|
+
(fDone) =>
|
|
547
|
+
{
|
|
548
|
+
_MockServerData.ActiveBooks = ACTIVE_BOOKS.slice();
|
|
549
|
+
_MockServerData.DeletedBooks = DELETED_BOOKS.slice();
|
|
550
|
+
|
|
551
|
+
_Fable = createTestFable();
|
|
552
|
+
setupSQLiteProvider(_Fable,
|
|
553
|
+
(pError) =>
|
|
554
|
+
{
|
|
555
|
+
if (pError) return fDone(pError);
|
|
556
|
+
setupSyncServices(_Fable, 'Ongoing', true, fDone);
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
);
|
|
560
|
+
|
|
561
|
+
test
|
|
562
|
+
(
|
|
563
|
+
'should sync active and deleted records via ongoing strategy',
|
|
564
|
+
(fDone) =>
|
|
565
|
+
{
|
|
566
|
+
_Fable.MeadowSync.syncAll(
|
|
567
|
+
(pError) =>
|
|
568
|
+
{
|
|
569
|
+
Expect(pError).to.not.exist;
|
|
570
|
+
|
|
571
|
+
let tmpAllLocal = getLocalBooks(_Fable);
|
|
572
|
+
let tmpDeletedLocal = getLocalDeletedBooks(_Fable);
|
|
573
|
+
let tmpActiveLocal = getLocalActiveBooks(_Fable);
|
|
574
|
+
|
|
575
|
+
Expect(tmpAllLocal.length).to.equal(8,
|
|
576
|
+
`Expected 8 total records, got ${tmpAllLocal.length}`);
|
|
577
|
+
Expect(tmpActiveLocal.length).to.equal(5,
|
|
578
|
+
`Expected 5 active records, got ${tmpActiveLocal.length}`);
|
|
579
|
+
Expect(tmpDeletedLocal.length).to.equal(3,
|
|
580
|
+
`Expected 3 deleted records, got ${tmpDeletedLocal.length}`);
|
|
581
|
+
|
|
582
|
+
// Verify deleted record data
|
|
583
|
+
let tmpBook7 = tmpDeletedLocal.find((r) => r.IDBook === 7);
|
|
584
|
+
Expect(tmpBook7.Title).to.equal('Lost Horizons');
|
|
585
|
+
Expect(tmpBook7.Deleted).to.equal(1);
|
|
586
|
+
|
|
587
|
+
return fDone();
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
);
|
|
593
|
+
|
|
594
|
+
// ── Ongoing Sync: records deleted after initial sync ────────────────
|
|
595
|
+
|
|
596
|
+
suite
|
|
597
|
+
(
|
|
598
|
+
'Records deleted after initial sync are picked up on ongoing sync',
|
|
599
|
+
() =>
|
|
600
|
+
{
|
|
601
|
+
let _Fable = null;
|
|
602
|
+
|
|
603
|
+
test
|
|
604
|
+
(
|
|
605
|
+
'should detect and sync newly-deleted records',
|
|
606
|
+
function (fDone)
|
|
607
|
+
{
|
|
608
|
+
// Use a generous timeout for the two-phase sync
|
|
609
|
+
this.timeout(10000);
|
|
610
|
+
|
|
611
|
+
// Phase 1: Initial sync with no deleted records
|
|
612
|
+
_MockServerData.ActiveBooks = ALL_BOOKS.slice(0, 8).map(
|
|
613
|
+
(pBook) =>
|
|
614
|
+
{
|
|
615
|
+
return Object.assign({}, pBook, { Deleted: 0, DeleteDate: '', DeletingIDUser: 0 });
|
|
616
|
+
});
|
|
617
|
+
_MockServerData.DeletedBooks = [];
|
|
618
|
+
|
|
619
|
+
_Fable = createTestFable();
|
|
620
|
+
setupSQLiteProvider(_Fable,
|
|
621
|
+
(pSetupError) =>
|
|
622
|
+
{
|
|
623
|
+
if (pSetupError) return fDone(pSetupError);
|
|
624
|
+
setupSyncServices(_Fable, 'Initial', false,
|
|
625
|
+
(pSchemaError) =>
|
|
626
|
+
{
|
|
627
|
+
if (pSchemaError) return fDone(pSchemaError);
|
|
628
|
+
|
|
629
|
+
_Fable.MeadowSync.syncAll(
|
|
630
|
+
(pSyncError) =>
|
|
631
|
+
{
|
|
632
|
+
Expect(pSyncError).to.not.exist;
|
|
633
|
+
|
|
634
|
+
let tmpAfterInitial = getLocalBooks(_Fable);
|
|
635
|
+
Expect(tmpAfterInitial.length).to.equal(8,
|
|
636
|
+
`Expected 8 records after initial sync, got ${tmpAfterInitial.length}`);
|
|
637
|
+
|
|
638
|
+
let tmpDeletedAfterInitial = getLocalDeletedBooks(_Fable);
|
|
639
|
+
Expect(tmpDeletedAfterInitial.length).to.equal(0,
|
|
640
|
+
`Expected 0 deleted after initial, got ${tmpDeletedAfterInitial.length}`);
|
|
641
|
+
|
|
642
|
+
// Phase 2: Now mark records 3 and 5 as deleted on the source
|
|
643
|
+
_MockServerData.ActiveBooks = [
|
|
644
|
+
ACTIVE_BOOKS[0], // ID 1
|
|
645
|
+
ACTIVE_BOOKS[1], // ID 2
|
|
646
|
+
ACTIVE_BOOKS[3], // ID 4
|
|
647
|
+
makeBookRecord(6, 'Forgotten Tales', 'Fantasy', false),
|
|
648
|
+
makeBookRecord(7, 'Lost Horizons', 'Travel', false),
|
|
649
|
+
makeBookRecord(8, 'Discontinued Edition', 'Reference', false)
|
|
650
|
+
];
|
|
651
|
+
_MockServerData.DeletedBooks = [
|
|
652
|
+
makeBookRecord(3, 'Science Frontiers', 'Science', true),
|
|
653
|
+
makeBookRecord(5, 'Code Warriors', 'Technology', true)
|
|
654
|
+
];
|
|
655
|
+
|
|
656
|
+
// Create an Ongoing sync entity directly, reusing the
|
|
657
|
+
// same Fable/Meadow/SQLite context so local data persists.
|
|
658
|
+
_Fable.serviceManager.addServiceType('MeadowSyncEntityOngoing', libMeadowSyncEntityOngoing);
|
|
659
|
+
let tmpOngoingEntity = _Fable.serviceManager.instantiateServiceProviderWithoutRegistration(
|
|
660
|
+
'MeadowSyncEntityOngoing',
|
|
661
|
+
{
|
|
662
|
+
MeadowEntitySchema: _BookExtendedSchema.Tables.Book,
|
|
663
|
+
PageSize: 100,
|
|
664
|
+
SyncDeletedRecords: true
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
tmpOngoingEntity.initialize(
|
|
668
|
+
(pInitError) =>
|
|
669
|
+
{
|
|
670
|
+
// Init error from duplicate createTable is expected; continue
|
|
671
|
+
tmpOngoingEntity.sync(
|
|
672
|
+
(pOngoingError) =>
|
|
673
|
+
{
|
|
674
|
+
Expect(pOngoingError).to.not.exist;
|
|
675
|
+
|
|
676
|
+
let tmpAfterOngoing = getLocalBooks(_Fable);
|
|
677
|
+
let tmpDeletedAfterOngoing = getLocalDeletedBooks(_Fable);
|
|
678
|
+
let tmpActiveAfterOngoing = getLocalActiveBooks(_Fable);
|
|
679
|
+
|
|
680
|
+
Expect(tmpAfterOngoing.length).to.equal(8,
|
|
681
|
+
`Expected 8 total after ongoing, got ${tmpAfterOngoing.length}`);
|
|
682
|
+
Expect(tmpDeletedAfterOngoing.length).to.equal(2,
|
|
683
|
+
`Expected 2 deleted after ongoing, got ${tmpDeletedAfterOngoing.length}`);
|
|
684
|
+
Expect(tmpActiveAfterOngoing.length).to.equal(6,
|
|
685
|
+
`Expected 6 active after ongoing, got ${tmpActiveAfterOngoing.length}`);
|
|
686
|
+
|
|
687
|
+
// Verify the right records are deleted
|
|
688
|
+
let tmpDeletedIDs = tmpDeletedAfterOngoing.map((r) => r.IDBook);
|
|
689
|
+
Expect(tmpDeletedIDs).to.include(3);
|
|
690
|
+
Expect(tmpDeletedIDs).to.include(5);
|
|
691
|
+
|
|
692
|
+
return fDone();
|
|
693
|
+
});
|
|
694
|
+
});
|
|
695
|
+
});
|
|
696
|
+
});
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
);
|
|
702
|
+
|
|
703
|
+
// ── Old API workaround: SyncDeletedRecordsQueryString ───────────
|
|
704
|
+
|
|
705
|
+
suite
|
|
706
|
+
(
|
|
707
|
+
'Old API workaround with SyncDeletedRecordsQueryString',
|
|
708
|
+
() =>
|
|
709
|
+
{
|
|
710
|
+
let _Fable = null;
|
|
711
|
+
|
|
712
|
+
setup
|
|
713
|
+
(
|
|
714
|
+
(fDone) =>
|
|
715
|
+
{
|
|
716
|
+
_MockServerData.ActiveBooks = ACTIVE_BOOKS.slice();
|
|
717
|
+
_MockServerData.DeletedBooks = DELETED_BOOKS.slice();
|
|
718
|
+
// Simulate old API: FBV~Deleted~EQ~1 returns 0 unless ?includeDeleted=true
|
|
719
|
+
_MockServerData.SimulateOldAPI = true;
|
|
720
|
+
|
|
721
|
+
_Fable = createTestFable();
|
|
722
|
+
setupSQLiteProvider(_Fable,
|
|
723
|
+
(pError) =>
|
|
724
|
+
{
|
|
725
|
+
if (pError) return fDone(pError);
|
|
726
|
+
|
|
727
|
+
// Configure with the query string workaround for the Book entity
|
|
728
|
+
let tmpEntityOptions =
|
|
729
|
+
{
|
|
730
|
+
Book: { SyncDeletedRecordsQueryString: 'includeDeleted=true' }
|
|
731
|
+
};
|
|
732
|
+
|
|
733
|
+
setupSyncServices(_Fable, 'Initial', true, fDone, tmpEntityOptions);
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
);
|
|
737
|
+
|
|
738
|
+
teardown
|
|
739
|
+
(
|
|
740
|
+
() =>
|
|
741
|
+
{
|
|
742
|
+
_MockServerData.SimulateOldAPI = false;
|
|
743
|
+
}
|
|
744
|
+
);
|
|
745
|
+
|
|
746
|
+
test
|
|
747
|
+
(
|
|
748
|
+
'should sync deleted records via ?includeDeleted=true on old API',
|
|
749
|
+
(fDone) =>
|
|
750
|
+
{
|
|
751
|
+
_Fable.MeadowSync.syncAll(
|
|
752
|
+
(pError) =>
|
|
753
|
+
{
|
|
754
|
+
Expect(pError).to.not.exist;
|
|
755
|
+
|
|
756
|
+
let tmpAllLocal = getLocalBooks(_Fable);
|
|
757
|
+
let tmpDeletedLocal = getLocalDeletedBooks(_Fable);
|
|
758
|
+
let tmpActiveLocal = getLocalActiveBooks(_Fable);
|
|
759
|
+
|
|
760
|
+
Expect(tmpAllLocal.length).to.equal(8,
|
|
761
|
+
`Expected 8 total records, got ${tmpAllLocal.length}`);
|
|
762
|
+
Expect(tmpActiveLocal.length).to.equal(5,
|
|
763
|
+
`Expected 5 active records, got ${tmpActiveLocal.length}`);
|
|
764
|
+
Expect(tmpDeletedLocal.length).to.equal(3,
|
|
765
|
+
`Expected 3 deleted records, got ${tmpDeletedLocal.length}`);
|
|
766
|
+
|
|
767
|
+
return fDone();
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
);
|
|
771
|
+
|
|
772
|
+
test
|
|
773
|
+
(
|
|
774
|
+
'should get 0 deleted records without the query string workaround on old API',
|
|
775
|
+
(fDone) =>
|
|
776
|
+
{
|
|
777
|
+
// Re-setup WITHOUT the query string workaround
|
|
778
|
+
let tmpFable2 = createTestFable();
|
|
779
|
+
setupSQLiteProvider(tmpFable2,
|
|
780
|
+
(pError) =>
|
|
781
|
+
{
|
|
782
|
+
if (pError) return fDone(pError);
|
|
783
|
+
|
|
784
|
+
// No SyncEntityOptions — standard FBV approach only
|
|
785
|
+
setupSyncServices(tmpFable2, 'Initial', true, (pSetupError) =>
|
|
786
|
+
{
|
|
787
|
+
if (pSetupError) return fDone(pSetupError);
|
|
788
|
+
|
|
789
|
+
tmpFable2.MeadowSync.syncAll(
|
|
790
|
+
(pSyncError) =>
|
|
791
|
+
{
|
|
792
|
+
Expect(pSyncError).to.not.exist;
|
|
793
|
+
|
|
794
|
+
let tmpDeletedLocal = tmpFable2.MeadowSQLiteProvider.db
|
|
795
|
+
.prepare('SELECT COUNT(*) as cnt FROM Book WHERE Deleted = 1').get();
|
|
796
|
+
|
|
797
|
+
Expect(tmpDeletedLocal.cnt).to.equal(0,
|
|
798
|
+
'Without workaround, old API returns 0 deleted records');
|
|
799
|
+
|
|
800
|
+
return fDone();
|
|
801
|
+
});
|
|
802
|
+
});
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
);
|