meadow-integration 1.0.40 → 1.0.42

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 (75) hide show
  1. package/BUILDING-AND-PUBLISHING.md +2 -2
  2. package/Dockerfile +1 -1
  3. package/README.md +27 -8
  4. package/docs/README.md +1 -1
  5. package/docs/_brand.json +18 -0
  6. package/docs/_cover.md +1 -1
  7. package/docs/_topbar.md +1 -1
  8. package/docs/_version.json +3 -3
  9. package/docs/architecture.md +14 -225
  10. package/docs/data-clone/configuration.md +9 -1
  11. package/docs/data-clone/diagrams/architecture-diagram.excalidraw +1756 -0
  12. package/docs/data-clone/diagrams/architecture-diagram.mmd +8 -0
  13. package/docs/data-clone/diagrams/architecture-diagram.svg +2 -0
  14. package/docs/data-clone/overview.md +2 -32
  15. package/docs/diagrams/configuration-cascade-2.excalidraw +831 -0
  16. package/docs/diagrams/configuration-cascade-2.mmd +8 -0
  17. package/docs/diagrams/configuration-cascade-2.svg +2 -0
  18. package/docs/diagrams/configuration-cascade.excalidraw +831 -0
  19. package/docs/diagrams/configuration-cascade.mmd +8 -0
  20. package/docs/diagrams/configuration-cascade.svg +2 -0
  21. package/docs/diagrams/data-synchronization-pipeline.excalidraw +3278 -0
  22. package/docs/diagrams/data-synchronization-pipeline.mmd +42 -0
  23. package/docs/diagrams/data-synchronization-pipeline.svg +2 -0
  24. package/docs/diagrams/data-transformation-pipeline.excalidraw +2929 -0
  25. package/docs/diagrams/data-transformation-pipeline.mmd +31 -0
  26. package/docs/diagrams/data-transformation-pipeline.svg +2 -0
  27. package/docs/diagrams/docker-deployment.excalidraw +1963 -0
  28. package/docs/diagrams/docker-deployment.mmd +23 -0
  29. package/docs/diagrams/docker-deployment.svg +2 -0
  30. package/docs/diagrams/high-level-system-architecture.excalidraw +5752 -0
  31. package/docs/diagrams/high-level-system-architecture.mmd +66 -0
  32. package/docs/diagrams/high-level-system-architecture.svg +2 -0
  33. package/docs/diagrams/module-structure.excalidraw +15206 -0
  34. package/docs/diagrams/module-structure.mmd +56 -0
  35. package/docs/diagrams/module-structure.svg +2 -0
  36. package/docs/diagrams/sync-mode-comparison.excalidraw +3660 -0
  37. package/docs/diagrams/sync-mode-comparison.mmd +33 -0
  38. package/docs/diagrams/sync-mode-comparison.svg +2 -0
  39. package/docs/implementation-reference.md +2 -58
  40. package/docs/index.html +6 -7
  41. package/docs/retold-catalog.json +388 -279
  42. package/docs/retold-keyword-index.json +24887 -16186
  43. package/example-applications/mapping-demo/README.md +2 -10
  44. package/example-applications/mapping-demo/diagrams/architecture.excalidraw +1866 -0
  45. package/example-applications/mapping-demo/diagrams/architecture.mmd +8 -0
  46. package/example-applications/mapping-demo/diagrams/architecture.svg +2 -0
  47. package/example-applications/mapping-demo/package.json +22 -1
  48. package/example-applications/mapping-demo/server.js +28 -0
  49. package/example-applications/mapping-demo/source/MappingDemoApp.js +42 -3
  50. package/example-applications/mapping-demo/source/MappingDemoBrand.js +17 -0
  51. package/example-applications/mapping-demo/web/favicons/apple-touch-icon.png +0 -0
  52. package/example-applications/mapping-demo/web/favicons/favicon-16.png +0 -0
  53. package/example-applications/mapping-demo/web/favicons/favicon-192.png +0 -0
  54. package/example-applications/mapping-demo/web/favicons/favicon-32.png +0 -0
  55. package/example-applications/mapping-demo/web/favicons/favicon-48.png +0 -0
  56. package/example-applications/mapping-demo/web/favicons/favicon-512.png +0 -0
  57. package/example-applications/mapping-demo/web/favicons/favicon-64.png +0 -0
  58. package/example-applications/mapping-demo/web/favicons/favicon-dark.svg +30 -0
  59. package/example-applications/mapping-demo/web/favicons/favicon-light.svg +30 -0
  60. package/example-applications/mapping-demo/web/favicons/favicon.svg +30 -0
  61. package/example-applications/mapping-demo/web/index.html +40 -26
  62. package/example-applications/mapping-demo/web/mapping-demo-editor.js +3267 -398
  63. package/example-applications/mapping-demo/web/mapping-demo-editor.js.map +1 -1
  64. package/example-applications/mapping-demo/web/mapping-demo-editor.min.js +34 -1
  65. package/example-applications/mapping-demo/web/mapping-demo-editor.min.js.map +1 -1
  66. package/example-applications/mapping-demo/web/pict.min.js +2 -2
  67. package/package.json +10 -7
  68. package/source/services/clone/Meadow-Service-DeleteCursorStore.js +105 -0
  69. package/source/services/clone/Meadow-Service-Sync-Entity-OngoingEventualConsistency.js +327 -92
  70. package/source/services/clone/Meadow-Service-Sync.js +2 -0
  71. package/source/views/PictView-MeadowMappingEditor.js +30 -30
  72. package/test/Meadow-Integration-BisectionSync_test.js +15 -5
  73. package/test/Meadow-Integration-NewStrategies_test.js +15 -5
  74. package/test/Meadow-Integration-OngoingEventualConsistencyDeleteCursor_test.js +228 -0
  75. package/test/Meadow-Integration-OngoingEventualConsistencyDeleteSync_test.js +311 -0
@@ -40,21 +40,21 @@ const _ViewConfiguration =
40
40
  font-weight: 600;
41
41
  text-transform: uppercase;
42
42
  letter-spacing: 0.5px;
43
- color: var(--facto-text-tertiary, #a09070);
43
+ color: var(--theme-color-text-muted, #a09070);
44
44
  padding: 0.5em 0.4em;
45
- border-bottom: 1px solid var(--facto-border, #d6c8ae);
45
+ border-bottom: 1px solid var(--theme-color-border-default, #d6c8ae);
46
46
  }
47
47
  .meadow-mapping-list-table td {
48
48
  padding: 0.35em 0.4em;
49
- border-bottom: 1px solid var(--facto-border-subtle, #e8ddc8);
49
+ border-bottom: 1px solid var(--theme-color-border-light, #e8ddc8);
50
50
  vertical-align: middle;
51
51
  }
52
52
  .meadow-flow-container {
53
53
  width: 100%;
54
54
  height: 500px;
55
- border: 1px solid var(--facto-border, #d6c8ae);
55
+ border: 1px solid var(--theme-color-border-default, #d6c8ae);
56
56
  border-radius: 6px;
57
- background: var(--facto-bg-surface, #fcf8f0);
57
+ background: var(--theme-color-background-secondary, #fcf8f0);
58
58
  margin-bottom: 0.75em;
59
59
  }
60
60
  .meadow-mapping-json-editor {
@@ -63,10 +63,10 @@ const _ViewConfiguration =
63
63
  font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
64
64
  font-size: 0.85em;
65
65
  padding: 0.75em;
66
- border: 1px solid var(--facto-border, #d6c8ae);
66
+ border: 1px solid var(--theme-color-border-default, #d6c8ae);
67
67
  border-radius: 6px;
68
- background: var(--facto-bg-input, #fcf8f0);
69
- color: var(--facto-text, #3a3020);
68
+ background: var(--theme-color-background-panel, #fcf8f0);
69
+ color: var(--theme-color-text-primary, #3a3020);
70
70
  resize: vertical;
71
71
  tab-size: 4;
72
72
  }
@@ -83,13 +83,13 @@ const _ViewConfiguration =
83
83
  font-size: 0.82em;
84
84
  cursor: pointer;
85
85
  padding: 0.3em 0.5em;
86
- border: 1px solid var(--facto-border-subtle, #e8ddc8);
86
+ border: 1px solid var(--theme-color-border-light, #e8ddc8);
87
87
  border-radius: 4px;
88
- background: var(--facto-bg-input, #fcf8f0);
88
+ background: var(--theme-color-background-panel, #fcf8f0);
89
89
  }
90
90
  .meadow-mapping-store-checklist label:has(input:checked) {
91
- border-color: var(--facto-brand, #18a5a0);
92
- background: var(--facto-brand-a12, rgba(24,165,160,0.12));
91
+ border-color: var(--theme-color-brand-primary, #18a5a0);
92
+ background: var(--theme-color-background-selected, rgba(24,165,160,0.12));
93
93
  }
94
94
  .meadow-mapping-btn {
95
95
  display: inline-flex;
@@ -105,20 +105,20 @@ const _ViewConfiguration =
105
105
  line-height: 1.4;
106
106
  }
107
107
  .meadow-mapping-btn-primary {
108
- background: var(--facto-brand, #18a5a0);
108
+ background: var(--theme-color-brand-primary, #18a5a0);
109
109
  color: var(--theme-color-background-panel, #fff);
110
- border-color: var(--facto-brand, #18a5a0);
110
+ border-color: var(--theme-color-brand-primary, #18a5a0);
111
111
  }
112
112
  .meadow-mapping-btn-primary:hover {
113
113
  opacity: 0.88;
114
114
  }
115
115
  .meadow-mapping-btn-secondary {
116
- background: var(--facto-bg-input, #fcf8f0);
117
- color: var(--facto-text, #3a3020);
118
- border-color: var(--facto-border, #d6c8ae);
116
+ background: var(--theme-color-background-panel, #fcf8f0);
117
+ color: var(--theme-color-text-primary, #3a3020);
118
+ border-color: var(--theme-color-border-default, #d6c8ae);
119
119
  }
120
120
  .meadow-mapping-btn-secondary:hover {
121
- background: var(--facto-border-subtle, #e8ddc8);
121
+ background: var(--theme-color-border-light, #e8ddc8);
122
122
  }
123
123
  .meadow-mapping-btn-danger {
124
124
  background: var(--theme-color-status-error, #e74c3c);
@@ -139,23 +139,23 @@ const _ViewConfiguration =
139
139
  .meadow-schema-mode-tab {
140
140
  padding: 0.25em 0.75em;
141
141
  font-size: 0.8em;
142
- border: 1px solid var(--facto-border, #d6c8ae);
142
+ border: 1px solid var(--theme-color-border-default, #d6c8ae);
143
143
  border-radius: 4px;
144
144
  cursor: pointer;
145
- background: var(--facto-bg-input, #fcf8f0);
146
- color: var(--facto-text, #3a3020);
145
+ background: var(--theme-color-background-panel, #fcf8f0);
146
+ color: var(--theme-color-text-primary, #3a3020);
147
147
  }
148
148
  .meadow-schema-mode-tab.active {
149
- background: var(--facto-brand, #18a5a0);
149
+ background: var(--theme-color-brand-primary, #18a5a0);
150
150
  color: var(--theme-color-background-panel, #fff);
151
- border-color: var(--facto-brand, #18a5a0);
151
+ border-color: var(--theme-color-brand-primary, #18a5a0);
152
152
  }
153
153
  .meadow-section-title {
154
154
  font-size: 0.72em;
155
155
  font-weight: 600;
156
156
  text-transform: uppercase;
157
157
  letter-spacing: 0.5px;
158
- color: var(--facto-text-tertiary, #a09070);
158
+ color: var(--theme-color-text-muted, #a09070);
159
159
  }
160
160
  `,
161
161
 
@@ -186,12 +186,12 @@ const _ViewConfiguration =
186
186
  <div id="MeadowMap-Detail" style="display:none;">
187
187
  <div style="display:flex; gap:0.5em; align-items:center; margin-bottom:0.75em;">
188
188
  <label style="font-size:0.78em; font-weight:600;">Mapping Name</label>
189
- <input type="text" id="MeadowMap-Name" placeholder="Mapping name" style="flex:1; padding:0.3em 0.5em; font-size:0.85em; border:1px solid var(--facto-border); border-radius:4px; background:var(--facto-bg-input); color:var(--facto-text);">
189
+ <input type="text" id="MeadowMap-Name" placeholder="Mapping name" style="flex:1; padding:0.3em 0.5em; font-size:0.85em; border:1px solid var(--theme-color-border-default); border-radius:4px; background:var(--theme-color-background-panel); color:var(--theme-color-text-primary);">
190
190
  </div>
191
191
 
192
192
  <div style="display:flex; gap:0.5em; align-items:center; margin-bottom:0.75em;">
193
193
  <label style="font-size:0.78em; font-weight:600;">Source</label>
194
- <select id="MeadowMap-Source" style="flex:1; padding:0.3em 0.5em; font-size:0.85em; border:1px solid var(--facto-border); border-radius:4px;"></select>
194
+ <select id="MeadowMap-Source" style="flex:1; padding:0.3em 0.5em; font-size:0.85em; border:1px solid var(--theme-color-border-default); border-radius:4px;"></select>
195
195
  <button class="meadow-mapping-btn meadow-mapping-btn-secondary meadow-mapping-btn-small" onclick="{~P~}.views['MeadowMappingEditor'].discoverSourceFields()">Discover Fields</button>
196
196
  </div>
197
197
 
@@ -204,7 +204,7 @@ const _ViewConfiguration =
204
204
  </div>
205
205
 
206
206
  <div style="margin-top:0.75em;">
207
- <div style="font-size:0.72em; font-weight:600; text-transform:uppercase; letter-spacing:0.5px; color:var(--facto-text-tertiary); margin-bottom:0.35em;">Target Stores</div>
207
+ <div style="font-size:0.72em; font-weight:600; text-transform:uppercase; letter-spacing:0.5px; color:var(--theme-color-text-muted); margin-bottom:0.35em;">Target Stores</div>
208
208
  <div id="MeadowMap-Stores" class="meadow-mapping-store-checklist"></div>
209
209
  </div>
210
210
 
@@ -428,7 +428,7 @@ class MeadowMappingEditorView extends libPictView
428
428
 
429
429
  if (this._CurrentMappings.length === 0)
430
430
  {
431
- tmpContainer.innerHTML = '<div style="text-align:center; padding:1.5em; color:var(--facto-text-tertiary, #a09070);">No mappings yet. Create one to map source fields to target columns.</div>';
431
+ tmpContainer.innerHTML = '<div style="text-align:center; padding:1.5em; color:var(--theme-color-text-muted, #a09070);">No mappings yet. Create one to map source fields to target columns.</div>';
432
432
  return;
433
433
  }
434
434
 
@@ -725,7 +725,7 @@ class MeadowMappingEditorView extends libPictView
725
725
 
726
726
  if (this._MappingStores.length === 0)
727
727
  {
728
- tmpContainer.innerHTML = '<div style="font-size:0.82em; color:var(--facto-text-tertiary, #a09070);">No stores configured yet.</div>';
728
+ tmpContainer.innerHTML = '<div style="font-size:0.82em; color:var(--theme-color-text-muted, #a09070);">No stores configured yet.</div>';
729
729
  return;
730
730
  }
731
731
 
@@ -1035,7 +1035,7 @@ class MeadowMappingEditorView extends libPictView
1035
1035
  catch (pFlowError)
1036
1036
  {
1037
1037
  this.log.error('Failed to initialize flow view: ' + pFlowError.message);
1038
- tmpFlowContainer.innerHTML = '<div style="padding:2em; text-align:center; color:var(--facto-text-tertiary, #a09070);">Flow editor could not be loaded. Use JSON Config mode instead.</div>';
1038
+ tmpFlowContainer.innerHTML = '<div style="padding:2em; text-align:center; color:var(--theme-color-text-muted, #a09070);">Flow editor could not be loaded. Use JSON Config mode instead.</div>';
1039
1039
  }
1040
1040
  }
1041
1041
 
@@ -428,18 +428,28 @@ function setupSQLiteProvider(pFable, fCallback)
428
428
 
429
429
  function seedLocalBooks(pFable, pBooks)
430
430
  {
431
- const tmpInsert = pFable.MeadowSQLiteProvider.db.prepare(`
431
+ // node:sqlite's DatabaseSync has no `.transaction(fn)` helper (that was
432
+ // a better-sqlite3 idiom). Bracket the bulk insert manually so seeding
433
+ // many thousands of rows doesn't pay per-row commit overhead.
434
+ const tmpDB = pFable.MeadowSQLiteProvider.db;
435
+ const tmpInsert = tmpDB.prepare(`
432
436
  INSERT OR REPLACE INTO Book (IDBook, GUIDBook, CreateDate, CreatingIDUser, UpdateDate, UpdatingIDUser, Deleted, DeleteDate, DeletingIDUser, Title, Type, Genre, PublicationYear)
433
437
  VALUES (@IDBook, @GUIDBook, @CreateDate, @CreatingIDUser, @UpdateDate, @UpdatingIDUser, @Deleted, @DeleteDate, @DeletingIDUser, @Title, @Type, @Genre, @PublicationYear)
434
438
  `);
435
- const tmpInsertMany = pFable.MeadowSQLiteProvider.db.transaction((pRecords) =>
439
+ tmpDB.exec('BEGIN');
440
+ try
436
441
  {
437
- for (const tmpRecord of pRecords)
442
+ for (const tmpRecord of pBooks)
438
443
  {
439
444
  tmpInsert.run(tmpRecord);
440
445
  }
441
- });
442
- tmpInsertMany(pBooks);
446
+ tmpDB.exec('COMMIT');
447
+ }
448
+ catch (pError)
449
+ {
450
+ tmpDB.exec('ROLLBACK');
451
+ throw pError;
452
+ }
443
453
  }
444
454
 
445
455
  function getLocalBookCount(pFable)
@@ -552,18 +552,28 @@ function setupSQLiteProvider(pFable, fCallback)
552
552
 
553
553
  function seedLocalBooks(pFable, pBooks)
554
554
  {
555
- const tmpInsert = pFable.MeadowSQLiteProvider.db.prepare(`
555
+ // node:sqlite's DatabaseSync has no `.transaction(fn)` helper (that was
556
+ // a better-sqlite3 idiom). Bracket the bulk insert manually so seeding
557
+ // many thousands of rows doesn't pay per-row commit overhead.
558
+ const tmpDB = pFable.MeadowSQLiteProvider.db;
559
+ const tmpInsert = tmpDB.prepare(`
556
560
  INSERT OR REPLACE INTO Book (IDBook, GUIDBook, CreateDate, CreatingIDUser, UpdateDate, UpdatingIDUser, Deleted, DeleteDate, DeletingIDUser, Title, Type, Genre, PublicationYear)
557
561
  VALUES (@IDBook, @GUIDBook, @CreateDate, @CreatingIDUser, @UpdateDate, @UpdatingIDUser, @Deleted, @DeleteDate, @DeletingIDUser, @Title, @Type, @Genre, @PublicationYear)
558
562
  `);
559
- const tmpInsertMany = pFable.MeadowSQLiteProvider.db.transaction((pRecords) =>
563
+ tmpDB.exec('BEGIN');
564
+ try
560
565
  {
561
- for (const tmpRecord of pRecords)
566
+ for (const tmpRecord of pBooks)
562
567
  {
563
568
  tmpInsert.run(tmpRecord);
564
569
  }
565
- });
566
- tmpInsertMany(pBooks);
570
+ tmpDB.exec('COMMIT');
571
+ }
572
+ catch (pError)
573
+ {
574
+ tmpDB.exec('ROLLBACK');
575
+ throw pError;
576
+ }
567
577
  }
568
578
 
569
579
  function getLocalBookCount(pFable)
@@ -0,0 +1,228 @@
1
+ /*
2
+ Unit tests for the resumable (head/tail) delete cursor.
3
+
4
+ Drives OngoingEventualConsistency.syncDeletedRecords with DeleteCursorStatePath
5
+ set, simulating successive "runs" (each call re-reads the JSON state file, as a
6
+ fresh container would). Verifies the tail drains across runs, the head pass
7
+ picks up new high-id deletions, the caught-up steady state, and that state
8
+ persists in the JSON file.
9
+
10
+ Mock server serves the keyset deleted-page queries
11
+ (FBV~Deleted~EQ~1[~FBV~IDBook~GT~N][~FBV~IDBook~LT~M]~FSF~IDBook~DESC~DESC).
12
+ */
13
+
14
+ const Chai = require('chai');
15
+ const Expect = Chai.expect;
16
+
17
+ const libHTTP = require('http');
18
+ const libFS = require('fs');
19
+ const libOS = require('os');
20
+ const libPath = require('path');
21
+ const libFable = require('fable');
22
+ const libMeadowConnectionSQLite = require('meadow-connection-sqlite');
23
+
24
+ const libMeadowCloneRestClient = require('../source/services/clone/Meadow-Service-RestClient.js');
25
+ const libMeadowSync = require('../source/services/clone/Meadow-Service-Sync.js');
26
+
27
+ const MOCK_PORT = 18095;
28
+ const MOCK_BASE_URL = `http://localhost:${MOCK_PORT}/1.0/`;
29
+ const STATE_PATH = libPath.join(libOS.tmpdir(), `oec-delete-cursor-${process.pid}.json`);
30
+
31
+ const _BookSchema =
32
+ {
33
+ TableName: 'Book',
34
+ Columns:
35
+ [
36
+ { Column: 'IDBook', DataType: 'int' },
37
+ { Column: 'GUIDBook', DataType: 'GUID' },
38
+ { Column: 'CreateDate', DataType: 'DateTime' },
39
+ { Column: 'CreatingIDUser', DataType: 'int' },
40
+ { Column: 'UpdateDate', DataType: 'DateTime' },
41
+ { Column: 'UpdatingIDUser', DataType: 'int' },
42
+ { Column: 'Deleted', DataType: 'int' },
43
+ { Column: 'DeleteDate', DataType: 'DateTime' },
44
+ { Column: 'DeletingIDUser', DataType: 'int' },
45
+ { Column: 'Title', DataType: 'String' }
46
+ ],
47
+ MeadowSchema:
48
+ {
49
+ Scope: 'Book', DefaultIdentifier: 'IDBook', Domain: 'Default',
50
+ Schema:
51
+ [
52
+ { Column: 'IDBook', Type: 'AutoIdentity', Size: 'Default' },
53
+ { Column: 'GUIDBook', Type: 'AutoGUID', Size: '128' },
54
+ { Column: 'CreateDate', Type: 'CreateDate', Size: 'Default' },
55
+ { Column: 'CreatingIDUser', Type: 'CreateIDUser', Size: 'int' },
56
+ { Column: 'UpdateDate', Type: 'UpdateDate', Size: 'Default' },
57
+ { Column: 'UpdatingIDUser', Type: 'UpdateIDUser', Size: 'int' },
58
+ { Column: 'Deleted', Type: 'Deleted', Size: 'Default' },
59
+ { Column: 'DeleteDate', Type: 'DeleteDate', Size: 'Default' },
60
+ { Column: 'DeletingIDUser', Type: 'DeleteIDUser', Size: 'int' },
61
+ { Column: 'Title', Type: 'String', Size: '200' }
62
+ ],
63
+ DefaultObject: { IDBook: 0, GUIDBook: '', CreateDate: null, CreatingIDUser: 0, UpdateDate: null, UpdatingIDUser: 0, Deleted: 0, DeleteDate: null, DeletingIDUser: 0, Title: '' },
64
+ JsonSchema: { title: 'Book', type: 'object', properties: { IDBook: { type: 'integer' } }, required: ['IDBook'] }
65
+ }
66
+ };
67
+
68
+ // Server-deleted ids: 10,20,...,100 (mutable so a test can add a new one).
69
+ let _ServerDeletedIDs = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
70
+ function serverDeletedRecord(pID)
71
+ {
72
+ return { IDBook: pID, GUIDBook: `GUID-${pID}`, Deleted: 1, DeleteDate: '2025-07-01T00:00:00.000Z', DeletingIDUser: 1, Title: `Deleted-${pID}` };
73
+ }
74
+
75
+ function createMockServer()
76
+ {
77
+ return libHTTP.createServer((pRequest, pResponse) =>
78
+ {
79
+ const tmpURL = pRequest.url;
80
+ pResponse.setHeader('Content-Type', 'application/json');
81
+
82
+ const tmpMatch = tmpURL.match(/\/1\.0\/Books\/FilteredTo\/(.+?)\/(\d+)\/(\d+)(\?|$)/);
83
+ if (tmpMatch && tmpMatch[1].indexOf('FBV~Deleted~EQ~1') > -1)
84
+ {
85
+ const tmpFilter = tmpMatch[1];
86
+ const tmpOffset = parseInt(tmpMatch[2], 10) || 0;
87
+ const tmpPageSize = parseInt(tmpMatch[3], 10) || 100;
88
+ const tmpGT = tmpFilter.match(/FBV~IDBook~GT~(\d+)/);
89
+ const tmpLT = tmpFilter.match(/FBV~IDBook~LT~(\d+)/);
90
+
91
+ let tmpIDs = _ServerDeletedIDs.slice();
92
+ if (tmpGT) { const n = parseInt(tmpGT[1], 10); tmpIDs = tmpIDs.filter((id) => id > n); }
93
+ if (tmpLT) { const n = parseInt(tmpLT[1], 10); tmpIDs = tmpIDs.filter((id) => id < n); }
94
+ tmpIDs.sort((a, b) => b - a); // DESC
95
+ const tmpPage = tmpIDs.slice(tmpOffset, tmpOffset + tmpPageSize).map(serverDeletedRecord);
96
+ pResponse.end(JSON.stringify(tmpPage));
97
+ return;
98
+ }
99
+
100
+ pResponse.statusCode = 404;
101
+ pResponse.end(JSON.stringify({ Error: `Unknown endpoint: ${tmpURL}` }));
102
+ });
103
+ }
104
+
105
+ function createTestFable()
106
+ {
107
+ const tmpFable = new libFable({ Product: 'OECDeleteCursorTest', MeadowProvider: 'SQLite', SQLite: { SQLiteFilePath: ':memory:' }, LogStreams: [{ streamtype: 'console', level: 'error' }] });
108
+ tmpFable.ProgramConfiguration = {};
109
+ return tmpFable;
110
+ }
111
+
112
+ function readState() { try { return JSON.parse(libFS.readFileSync(STATE_PATH, 'utf8')); } catch (e) { return {}; } }
113
+ function deletedCount(pFable) { return pFable.MeadowSQLiteProvider.db.prepare('SELECT COUNT(*) c FROM Book WHERE Deleted=1').get().c; }
114
+
115
+ suite
116
+ (
117
+ 'OngoingEventualConsistency resumable delete cursor',
118
+ () =>
119
+ {
120
+ let _MockServer = null;
121
+ let _Fable = null;
122
+ let _Entity = null;
123
+
124
+ suiteSetup((fDone) => { _MockServer = createMockServer(); _MockServer.listen(MOCK_PORT, fDone); });
125
+ suiteTeardown((fDone) => { try { libFS.unlinkSync(STATE_PATH); } catch (e) {} if (_MockServer) { _MockServer.close(fDone); } else { return fDone(); } });
126
+
127
+ setup((fDone) =>
128
+ {
129
+ _ServerDeletedIDs = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
130
+ try { libFS.unlinkSync(STATE_PATH); } catch (e) {}
131
+
132
+ _Fable = createTestFable();
133
+ _Fable.serviceManager.addServiceType('MeadowSQLiteProvider', libMeadowConnectionSQLite);
134
+ _Fable.serviceManager.instantiateServiceProvider('MeadowSQLiteProvider');
135
+ _Fable.MeadowSQLiteProvider.connectAsync((pErr) =>
136
+ {
137
+ if (pErr) return fDone(pErr);
138
+ _Fable.MeadowSQLiteProvider.db.exec(`CREATE TABLE IF NOT EXISTS Book (
139
+ IDBook INTEGER PRIMARY KEY AUTOINCREMENT, GUIDBook TEXT DEFAULT '', CreateDate TEXT DEFAULT '',
140
+ CreatingIDUser INTEGER DEFAULT 0, UpdateDate TEXT DEFAULT '', UpdatingIDUser INTEGER DEFAULT 0,
141
+ Deleted INTEGER DEFAULT 0, DeleteDate TEXT DEFAULT '', DeletingIDUser INTEGER DEFAULT 0, Title TEXT DEFAULT '');`);
142
+
143
+ _Fable.serviceManager.addServiceType('MeadowCloneRestClient', libMeadowCloneRestClient);
144
+ _Fable.serviceManager.instantiateServiceProvider('MeadowCloneRestClient', { ServerURL: MOCK_BASE_URL });
145
+ _Fable.serviceManager.addServiceType('MeadowSync', libMeadowSync);
146
+ // PageSize 3 + MaxRecordsPerEntity 3 => one page per "run" so we can watch it resume.
147
+ _Fable.serviceManager.instantiateServiceProvider('MeadowSync',
148
+ { PageSize: 3, SyncDeletedRecords: true, BackSyncTimeLimit: 999999, MaxRecordsPerEntity: 3, DeleteCursorStatePath: STATE_PATH });
149
+ _Fable.MeadowSync.SyncMode = 'OngoingEventualConsistency';
150
+ _Fable.MeadowSync.SyncDeletedRecords = true;
151
+ _Fable.MeadowSync.BackSyncTimeLimit = 999999;
152
+ _Fable.MeadowSync.loadMeadowSchema({ Tables: { Book: _BookSchema } }, (pSchemaErr) =>
153
+ {
154
+ if (pSchemaErr) return fDone(pSchemaErr);
155
+ _Entity = _Fable.MeadowSync.MeadowSyncEntities['Book'];
156
+ _Entity.syncResults = { Created: 0, Updated: 0, Deleted: 0 };
157
+ // Seed all 10 as ACTIVE so each can be flagged when the cursor reaches it.
158
+ const tmpIns = _Fable.MeadowSQLiteProvider.db.prepare('INSERT INTO Book (IDBook, GUIDBook, Deleted, Title) VALUES (?, ?, 0, ?)');
159
+ for (const id of [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]) { tmpIns.run(id, `GUID-${id}`, `Active-${id}`); }
160
+ return fDone();
161
+ });
162
+ });
163
+ });
164
+
165
+ // Helper: run syncDeletedRecords once (a "run").
166
+ function run(fNext) { _Entity.syncResults = { Created: 0, Updated: 0, Deleted: 0 }; _Entity.syncDeletedRecords(fNext); }
167
+
168
+ test('drains the backlog across runs, resuming each time (no re-walk)', function (fDone)
169
+ {
170
+ this.timeout(20000);
171
+ // 10 deleted, 3 per run → ~4 runs to drain.
172
+ run(() =>
173
+ {
174
+ // Run 1: newest 3 (100,90,80) flagged; cursor advanced, not caught up.
175
+ Expect(deletedCount(_Fable)).to.equal(3, 'run 1 flags newest 3');
176
+ const s1 = readState().Book;
177
+ Expect(s1).to.be.an('object');
178
+ Expect(s1.HeadID).to.equal(100, 'head established at the top');
179
+ Expect(s1.TailID).to.equal(80, 'tail advanced to lowest examined');
180
+ Expect(s1.CaughtUp).to.equal(false);
181
+
182
+ run(() =>
183
+ {
184
+ Expect(deletedCount(_Fable)).to.equal(6, 'run 2 resumes: +3 (70,60,50)');
185
+ Expect(readState().Book.TailID).to.equal(50);
186
+ run(() =>
187
+ {
188
+ Expect(deletedCount(_Fable)).to.equal(9, 'run 3 resumes: +3 (40,30,20)');
189
+ run(() =>
190
+ {
191
+ // Run 4: only 10 left → flagged, then exhausted → caught up.
192
+ Expect(deletedCount(_Fable)).to.equal(10, 'run 4 flags the last one');
193
+ const s4 = readState().Book;
194
+ Expect(s4.CaughtUp).to.equal(true, 'tail reached the bottom → caught up');
195
+ return fDone();
196
+ });
197
+ });
198
+ });
199
+ });
200
+ });
201
+
202
+ test('once caught up, the head pass picks up a new high-id deletion cheaply', function (fDone)
203
+ {
204
+ this.timeout(20000);
205
+ // Drain fully first (4 runs).
206
+ run(() => run(() => run(() => run(() =>
207
+ {
208
+ Expect(deletedCount(_Fable)).to.equal(10);
209
+ Expect(readState().Book.CaughtUp).to.equal(true);
210
+ const tmpHeadBefore = readState().Book.HeadID;
211
+ Expect(tmpHeadBefore).to.equal(100);
212
+
213
+ // A new record (id 110) is created and deleted on the server; seed it locally as active.
214
+ _ServerDeletedIDs.push(110);
215
+ _Fable.MeadowSQLiteProvider.db.prepare('INSERT INTO Book (IDBook, GUIDBook, Deleted, Title) VALUES (110, ?, 0, ?)').run('GUID-110', 'Active-110');
216
+
217
+ run(() =>
218
+ {
219
+ // Head pass (id > 100) catches 110; tail stays caught up.
220
+ Expect(_Fable.MeadowSQLiteProvider.db.prepare('SELECT Deleted FROM Book WHERE IDBook=110').get().Deleted).to.equal(1, 'new high-id deletion flagged by head pass');
221
+ Expect(readState().Book.HeadID).to.equal(110, 'head advanced to the new max');
222
+ Expect(readState().Book.CaughtUp).to.equal(true, 'still caught up');
223
+ return fDone();
224
+ });
225
+ }))));
226
+ });
227
+ }
228
+ );