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.
Files changed (35) hide show
  1. package/example-applications/mapping-demo/.quackage.json +10 -0
  2. package/example-applications/mapping-demo/README.md +99 -0
  3. package/example-applications/mapping-demo/data/books-sample.csv +21 -0
  4. package/example-applications/mapping-demo/generate-build-config.js +44 -0
  5. package/example-applications/mapping-demo/mappings/books-to-book.json +14 -0
  6. package/example-applications/mapping-demo/package.json +14 -0
  7. package/example-applications/mapping-demo/server.js +814 -0
  8. package/example-applications/mapping-demo/source/MappingDemoApp.js +52 -0
  9. package/example-applications/mapping-demo/source/views/MappingDemoEditorView.js +186 -0
  10. package/example-applications/mapping-demo/web/index.html +892 -0
  11. package/example-applications/mapping-demo/web/mapping-demo-editor.js +3195 -0
  12. package/example-applications/mapping-demo/web/mapping-demo-editor.js.map +1 -0
  13. package/example-applications/mapping-demo/web/mapping-demo-editor.min.js +2 -0
  14. package/example-applications/mapping-demo/web/mapping-demo-editor.min.js.map +1 -0
  15. package/example-applications/mapping-demo/web/pict.min.js +12 -0
  16. package/package.json +11 -5
  17. package/source/Meadow-Integration-Browser.js +31 -0
  18. package/source/Meadow-Integration.js +30 -1
  19. package/source/services/certainty/Service-CertaintyAccumulator.js +402 -0
  20. package/source/services/clone/Meadow-Service-Sync-Entity-Initial.js +16 -3
  21. package/source/services/clone/Meadow-Service-Sync-Entity-Ongoing.js +15 -2
  22. package/source/services/clone/Meadow-Service-Sync.js +21 -0
  23. package/source/services/parser/Service-FileParser-CSV.js +263 -0
  24. package/source/services/parser/Service-FileParser-FixedWidth.js +158 -0
  25. package/source/services/parser/Service-FileParser-JSON.js +255 -0
  26. package/source/services/parser/Service-FileParser-XLSX.js +194 -0
  27. package/source/services/parser/Service-FileParser-XML.js +190 -0
  28. package/source/services/parser/Service-FileParser.js +142 -0
  29. package/source/views/MappingEditor-SchemaUtils.js +71 -0
  30. package/source/views/PictView-MeadowMappingEditor.js +1299 -0
  31. package/source/views/flow-cards/FlowCard-MappingSource.js +50 -0
  32. package/source/views/flow-cards/FlowCard-MappingTarget.js +49 -0
  33. package/source/views/flow-cards/FlowCard-SolverExpression.js +78 -0
  34. package/source/views/flow-cards/FlowCard-TemplateExpression.js +77 -0
  35. 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
+ );