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
@@ -0,0 +1,311 @@
1
+ /*
2
+ Unit tests for OngoingEventualConsistency delete reconciliation.
3
+
4
+ Focused on the correctness fix: deleted server rows are matched to local
5
+ rows by GUID (not identity), so a cloned database that holds a record under
6
+ a DIFFERENT auto-increment id (the real-world failure mode) still gets the
7
+ row flagged deleted instead of attempting a CREATE that collides on the GUID
8
+ unique index.
9
+
10
+ Also covers: already-deleted rows are skipped, rows never synced live are
11
+ skipped (not created), and the structured report receives the real count.
12
+
13
+ Uses a mock HTTP server for the deleted-record API and an in-memory SQLite
14
+ database as the local clone destination. Calls syncDeletedRecords() directly
15
+ so the forward-sync phases don't need mocking.
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 libMeadowConnectionSQLite = require('meadow-connection-sqlite');
24
+
25
+ const libMeadowCloneRestClient = require('../source/services/clone/Meadow-Service-RestClient.js');
26
+ const libMeadowSync = require('../source/services/clone/Meadow-Service-Sync.js');
27
+
28
+ const MOCK_PORT = 18097;
29
+ const MOCK_BASE_URL = `http://localhost:${MOCK_PORT}/1.0/`;
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',
50
+ DefaultIdentifier: 'IDBook',
51
+ Domain: 'Default',
52
+ Schema:
53
+ [
54
+ { Column: 'IDBook', Type: 'AutoIdentity', Size: 'Default' },
55
+ { Column: 'GUIDBook', Type: 'AutoGUID', Size: '128' },
56
+ { Column: 'CreateDate', Type: 'CreateDate', Size: 'Default' },
57
+ { Column: 'CreatingIDUser', Type: 'CreateIDUser', Size: 'int' },
58
+ { Column: 'UpdateDate', Type: 'UpdateDate', Size: 'Default' },
59
+ { Column: 'UpdatingIDUser', Type: 'UpdateIDUser', Size: 'int' },
60
+ { Column: 'Deleted', Type: 'Deleted', Size: 'Default' },
61
+ { Column: 'DeleteDate', Type: 'DeleteDate', Size: 'Default' },
62
+ { Column: 'DeletingIDUser', Type: 'DeleteIDUser', Size: 'int' },
63
+ { Column: 'Title', Type: 'String', Size: '200' }
64
+ ],
65
+ DefaultObject:
66
+ {
67
+ IDBook: 0, GUIDBook: '', CreateDate: null, CreatingIDUser: 0,
68
+ UpdateDate: null, UpdatingIDUser: 0, Deleted: 0,
69
+ DeleteDate: null, DeletingIDUser: 0, Title: ''
70
+ },
71
+ JsonSchema:
72
+ {
73
+ title: 'Book', type: 'object',
74
+ properties:
75
+ {
76
+ IDBook: { type: 'integer' }, GUIDBook: { type: 'string' },
77
+ CreateDate: { type: 'string' }, CreatingIDUser: { type: 'integer' },
78
+ UpdateDate: { type: 'string' }, UpdatingIDUser: { type: 'integer' },
79
+ Deleted: { type: 'boolean' }, DeleteDate: { type: 'string' },
80
+ DeletingIDUser: { type: 'integer' }, Title: { type: 'string' }
81
+ },
82
+ required: ['IDBook']
83
+ }
84
+ }
85
+ };
86
+
87
+ // Server-side deleted set (ids 100-103). Returned newest-id-first.
88
+ function makeServerDeleted(pID)
89
+ {
90
+ return {
91
+ IDBook: pID,
92
+ GUIDBook: `GUID-BOOK-${pID}`,
93
+ CreateDate: '2025-01-01T00:00:00.000Z',
94
+ CreatingIDUser: 1,
95
+ UpdateDate: '2025-06-15T12:00:00.000Z',
96
+ UpdatingIDUser: 1,
97
+ Deleted: 1,
98
+ DeleteDate: '2025-07-01T00:00:00.000Z',
99
+ DeletingIDUser: 7,
100
+ Title: `Server-Deleted-${pID}`
101
+ };
102
+ }
103
+ const SERVER_DELETED = [ makeServerDeleted(100), makeServerDeleted(101), makeServerDeleted(102), makeServerDeleted(103) ];
104
+
105
+ function createMockServer()
106
+ {
107
+ return libHTTP.createServer(
108
+ (pRequest, pResponse) =>
109
+ {
110
+ const tmpURL = pRequest.url;
111
+ pResponse.setHeader('Content-Type', 'application/json');
112
+
113
+ // Count of deleted records
114
+ if (tmpURL.match(/\/1\.0\/Books\/Count\/FilteredTo\/FBV~Deleted~EQ~1/))
115
+ {
116
+ pResponse.end(JSON.stringify({ Count: SERVER_DELETED.length }));
117
+ return;
118
+ }
119
+
120
+ // Deleted page, newest-id first (DESC) — this is the new ordering.
121
+ if (tmpURL.match(/\/1\.0\/Books\/FilteredTo\/FBV~Deleted~EQ~1~FSF~IDBook~DESC~DESC/))
122
+ {
123
+ const tmpParts = tmpURL.split('?')[0].split('/');
124
+ const tmpOffset = parseInt(tmpParts[tmpParts.length - 2], 10) || 0;
125
+ const tmpPageSize = parseInt(tmpParts[tmpParts.length - 1], 10) || 100;
126
+ const tmpSortedDesc = SERVER_DELETED.slice().sort((a, b) => b.IDBook - a.IDBook);
127
+ pResponse.end(JSON.stringify(tmpSortedDesc.slice(tmpOffset, tmpOffset + tmpPageSize)));
128
+ return;
129
+ }
130
+
131
+ pResponse.statusCode = 404;
132
+ pResponse.end(JSON.stringify({ Error: `Unknown endpoint: ${tmpURL}` }));
133
+ });
134
+ }
135
+
136
+ function createTestFable()
137
+ {
138
+ const tmpFable = new libFable(
139
+ {
140
+ Product: 'OECDeleteSyncTest',
141
+ ProductVersion: '1.0.0',
142
+ MeadowProvider: 'SQLite',
143
+ SQLite: { SQLiteFilePath: ':memory:' },
144
+ LogStreams: [{ streamtype: 'console', level: 'error' }]
145
+ });
146
+ tmpFable.ProgramConfiguration = {};
147
+ return tmpFable;
148
+ }
149
+
150
+ function setupSQLite(pFable, fCallback)
151
+ {
152
+ pFable.serviceManager.addServiceType('MeadowSQLiteProvider', libMeadowConnectionSQLite);
153
+ pFable.serviceManager.instantiateServiceProvider('MeadowSQLiteProvider');
154
+ pFable.MeadowSQLiteProvider.connectAsync(
155
+ (pError) =>
156
+ {
157
+ if (pError) return fCallback(pError);
158
+ pFable.MeadowSQLiteProvider.db.exec(`
159
+ CREATE TABLE IF NOT EXISTS Book (
160
+ IDBook INTEGER PRIMARY KEY AUTOINCREMENT,
161
+ GUIDBook TEXT DEFAULT '',
162
+ CreateDate TEXT DEFAULT '',
163
+ CreatingIDUser INTEGER DEFAULT 0,
164
+ UpdateDate TEXT DEFAULT '',
165
+ UpdatingIDUser INTEGER DEFAULT 0,
166
+ Deleted INTEGER DEFAULT 0,
167
+ DeleteDate TEXT DEFAULT '',
168
+ DeletingIDUser INTEGER DEFAULT 0,
169
+ Title TEXT DEFAULT ''
170
+ );
171
+ `);
172
+ return fCallback();
173
+ });
174
+ }
175
+
176
+ function seedBook(pFable, pID, pGUID, pDeleted, pTitle)
177
+ {
178
+ pFable.MeadowSQLiteProvider.db.prepare(
179
+ 'INSERT INTO Book (IDBook, GUIDBook, Deleted, DeleteDate, Title) VALUES (?, ?, ?, ?, ?)')
180
+ .run(pID, pGUID, pDeleted, pDeleted ? '2025-07-01T00:00:00.000Z' : '', pTitle);
181
+ }
182
+
183
+ function allBooks(pFable)
184
+ {
185
+ return pFable.MeadowSQLiteProvider.db.prepare('SELECT * FROM Book ORDER BY IDBook').all();
186
+ }
187
+ function bookByGUID(pFable, pGUID)
188
+ {
189
+ return pFable.MeadowSQLiteProvider.db.prepare('SELECT * FROM Book WHERE GUIDBook = ?').all(pGUID);
190
+ }
191
+ function bookByID(pFable, pID)
192
+ {
193
+ return pFable.MeadowSQLiteProvider.db.prepare('SELECT * FROM Book WHERE IDBook = ?').get(pID);
194
+ }
195
+
196
+ suite
197
+ (
198
+ 'OngoingEventualConsistency delete reconciliation',
199
+ () =>
200
+ {
201
+ let _MockServer = null;
202
+
203
+ suiteSetup((fDone) => { _MockServer = createMockServer(); _MockServer.listen(MOCK_PORT, fDone); });
204
+ suiteTeardown((fDone) => { if (_MockServer) { _MockServer.close(fDone); } else { return fDone(); } });
205
+
206
+ let _Fable = null;
207
+ let _Entity = null;
208
+
209
+ setup
210
+ (
211
+ (fDone) =>
212
+ {
213
+ _Fable = createTestFable();
214
+ setupSQLite(_Fable,
215
+ (pError) =>
216
+ {
217
+ if (pError) return fDone(pError);
218
+
219
+ _Fable.serviceManager.addServiceType('MeadowCloneRestClient', libMeadowCloneRestClient);
220
+ _Fable.serviceManager.instantiateServiceProvider('MeadowCloneRestClient', { ServerURL: MOCK_BASE_URL });
221
+
222
+ // Wire through MeadowSync so fable.Meadow is established and the
223
+ // entity is built/initialized exactly as in production.
224
+ _Fable.serviceManager.addServiceType('MeadowSync', libMeadowSync);
225
+ _Fable.serviceManager.instantiateServiceProvider('MeadowSync',
226
+ { PageSize: 100, SyncDeletedRecords: true, BackSyncTimeLimit: 999999 });
227
+ _Fable.MeadowSync.SyncMode = 'OngoingEventualConsistency';
228
+ _Fable.MeadowSync.SyncDeletedRecords = true;
229
+ _Fable.MeadowSync.BackSyncTimeLimit = 999999;
230
+
231
+ _Fable.MeadowSync.loadMeadowSchema({ Tables: { Book: _BookSchema } },
232
+ (pSchemaError) =>
233
+ {
234
+ if (pSchemaError) return fDone(pSchemaError);
235
+ _Entity = _Fable.MeadowSync.MeadowSyncEntities['Book'];
236
+ _Entity.syncResults = { Created: 0, Updated: 0, Deleted: 0 };
237
+
238
+ // Seed local clone state AFTER the table exists. Server has
239
+ // deleted ids 100, 101, 102, 103.
240
+ // id 101 active -> matches a deleted id -> mark deleted
241
+ // id 102 already deleted -> skip
242
+ // id 100 absent -> not in clone -> skip (no create)
243
+ // id 777 active, GUID twin -> shares GUID-BOOK-103 with the deleted
244
+ // id 103 but under a DIFFERENT id. MUST
245
+ // NOT be touched (the duplicate-GUID trap).
246
+ // id 50 unrelated active -> untouched
247
+ seedBook(_Fable, 101, 'GUID-BOOK-101', 0, 'Normal-101');
248
+ seedBook(_Fable, 102, 'GUID-BOOK-102', 1, 'Already-102');
249
+ seedBook(_Fable, 777, 'GUID-BOOK-103', 0, 'GuidTwin-of-deleted-103');
250
+ seedBook(_Fable, 50, 'GUID-BOOK-50', 0, 'Untouched-50');
251
+
252
+ return fDone();
253
+ });
254
+ });
255
+ }
256
+ );
257
+
258
+ test
259
+ (
260
+ 'flags the row whose id matches the server-deleted id, and NEVER a GUID twin under a different id',
261
+ (fDone) =>
262
+ {
263
+ _Entity.syncDeletedRecords(
264
+ () =>
265
+ {
266
+ // id 101 matched a server-deleted id -> flagged deleted, DeleteDate stamped
267
+ const tmp101 = bookByID(_Fable, 101);
268
+ Expect(tmp101.Deleted).to.equal(1, 'id 101 flagged deleted');
269
+ Expect(tmp101.DeleteDate).to.be.a('string').and.not.equal('', 'DeleteDate stamped on delete');
270
+
271
+ // THE SAFETY CASE: id 777 shares GUID-BOOK-103 with the server-deleted id
272
+ // 103, but is a DIFFERENT, active record. It must be left untouched —
273
+ // deleting it would be data corruption from a duplicate GUID.
274
+ const tmp777 = bookByID(_Fable, 777);
275
+ Expect(tmp777.Deleted).to.equal(0, 'GUID twin under a different id MUST NOT be deleted');
276
+
277
+ // No local row at the server-deleted ids 100 or 103 was created.
278
+ Expect(bookByID(_Fable, 100)).to.be.undefined;
279
+ Expect(bookByID(_Fable, 103)).to.be.undefined;
280
+ return fDone();
281
+ });
282
+ }
283
+ );
284
+
285
+ test
286
+ (
287
+ 'skips already-deleted and not-in-clone rows; creates nothing; reports the real count',
288
+ (fDone) =>
289
+ {
290
+ _Entity.syncDeletedRecords(
291
+ () =>
292
+ {
293
+ // id 102 already deleted — still exactly one row, still deleted
294
+ const tmp102 = bookByGUID(_Fable, 'GUID-BOOK-102');
295
+ Expect(tmp102.length).to.equal(1);
296
+ Expect(tmp102[0].Deleted).to.equal(1);
297
+
298
+ // Unrelated active row untouched
299
+ Expect(bookByID(_Fable, 50).Deleted).to.equal(0);
300
+
301
+ // No rows created at all — total stays at the 4 seeded (101, 102, 777, 50)
302
+ Expect(allBooks(_Fable).length).to.equal(4, 'no ghost rows created');
303
+
304
+ // Only id 101 was newly flagged (102 already; 100/103 not in clone)
305
+ Expect(_Entity.syncResults.Deleted).to.equal(1, 'reports exactly the newly-flagged count');
306
+ return fDone();
307
+ });
308
+ }
309
+ );
310
+ }
311
+ );