meadow-integration 1.0.41 → 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.
- package/BUILDING-AND-PUBLISHING.md +2 -2
- package/README.md +27 -8
- package/docs/README.md +1 -1
- package/docs/_cover.md +1 -1
- package/docs/_topbar.md +1 -1
- package/docs/_version.json +3 -3
- package/docs/architecture.md +14 -225
- package/docs/css/docuserve.css +327 -0
- package/docs/data-clone/diagrams/architecture-diagram.excalidraw +1756 -0
- package/docs/data-clone/diagrams/architecture-diagram.mmd +8 -0
- package/docs/data-clone/diagrams/architecture-diagram.svg +2 -0
- package/docs/data-clone/overview.md +2 -32
- package/docs/diagrams/configuration-cascade-2.excalidraw +831 -0
- package/docs/diagrams/configuration-cascade-2.mmd +8 -0
- package/docs/diagrams/configuration-cascade-2.svg +2 -0
- package/docs/diagrams/configuration-cascade.excalidraw +831 -0
- package/docs/diagrams/configuration-cascade.mmd +8 -0
- package/docs/diagrams/configuration-cascade.svg +2 -0
- package/docs/diagrams/data-synchronization-pipeline.excalidraw +3278 -0
- package/docs/diagrams/data-synchronization-pipeline.mmd +42 -0
- package/docs/diagrams/data-synchronization-pipeline.svg +2 -0
- package/docs/diagrams/data-transformation-pipeline.excalidraw +2929 -0
- package/docs/diagrams/data-transformation-pipeline.mmd +31 -0
- package/docs/diagrams/data-transformation-pipeline.svg +2 -0
- package/docs/diagrams/docker-deployment.excalidraw +1963 -0
- package/docs/diagrams/docker-deployment.mmd +23 -0
- package/docs/diagrams/docker-deployment.svg +2 -0
- package/docs/diagrams/high-level-system-architecture.excalidraw +5752 -0
- package/docs/diagrams/high-level-system-architecture.mmd +66 -0
- package/docs/diagrams/high-level-system-architecture.svg +2 -0
- package/docs/diagrams/module-structure.excalidraw +15206 -0
- package/docs/diagrams/module-structure.mmd +56 -0
- package/docs/diagrams/module-structure.svg +2 -0
- package/docs/diagrams/sync-mode-comparison.excalidraw +3660 -0
- package/docs/diagrams/sync-mode-comparison.mmd +33 -0
- package/docs/diagrams/sync-mode-comparison.svg +2 -0
- package/docs/implementation-reference.md +2 -58
- package/docs/index.html +2 -2
- package/docs/retold-catalog.json +388 -284
- package/docs/retold-keyword-index.json +24830 -16291
- package/example-applications/mapping-demo/README.md +2 -10
- package/example-applications/mapping-demo/diagrams/architecture.excalidraw +1866 -0
- package/example-applications/mapping-demo/diagrams/architecture.mmd +8 -0
- package/example-applications/mapping-demo/diagrams/architecture.svg +2 -0
- package/example-applications/mapping-demo/package.json +0 -3
- package/example-applications/mapping-demo/web/mapping-demo-editor.js +5 -5
- package/example-applications/mapping-demo/web/mapping-demo-editor.min.js +2 -2
- package/example-applications/mapping-demo/web/pict.min.js +2 -2
- package/package.json +7 -7
- package/source/services/clone/Meadow-Service-DeleteCursorStore.js +105 -0
- package/source/services/clone/Meadow-Service-Sync-Entity-OngoingEventualConsistency.js +327 -92
- package/source/services/clone/Meadow-Service-Sync.js +2 -0
- package/test/Meadow-Integration-BisectionSync_test.js +15 -5
- package/test/Meadow-Integration-NewStrategies_test.js +15 -5
- package/test/Meadow-Integration-OngoingEventualConsistencyDeleteCursor_test.js +228 -0
- package/test/Meadow-Integration-OngoingEventualConsistencyDeleteSync_test.js +311 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "meadow-integration",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.42",
|
|
4
4
|
"description": "Meadow Data Integration",
|
|
5
5
|
"retoldBeacon": {
|
|
6
6
|
"displayName": "Meadow Integration",
|
|
@@ -59,8 +59,8 @@
|
|
|
59
59
|
"license": "MIT",
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"meadow-connection-sqlite": "^1.0.20",
|
|
62
|
-
"pict-docuserve": "^1.
|
|
63
|
-
"quackage": "^1.
|
|
62
|
+
"pict-docuserve": "^1.4.19",
|
|
63
|
+
"quackage": "^1.3.0"
|
|
64
64
|
},
|
|
65
65
|
"mocha": {
|
|
66
66
|
"diff": true,
|
|
@@ -84,15 +84,15 @@
|
|
|
84
84
|
"fable": "^3.1.75",
|
|
85
85
|
"fable-serviceproviderbase": "^3.0.19",
|
|
86
86
|
"fast-xml-parser": "^4.4.1",
|
|
87
|
-
"meadow": "^2.0.
|
|
87
|
+
"meadow": "^2.0.43",
|
|
88
88
|
"meadow-connection-mssql": "^1.0.23",
|
|
89
89
|
"meadow-connection-mysql": "^1.0.19",
|
|
90
90
|
"orator": "^6.1.2",
|
|
91
91
|
"orator-serviceserver-restify": "^2.0.11",
|
|
92
|
-
"pict-provider-theme": "^1.
|
|
92
|
+
"pict-provider-theme": "^1.1.2",
|
|
93
93
|
"pict-section-flow": "^1.0.1",
|
|
94
|
-
"pict-section-modal": "^1.1.
|
|
95
|
-
"pict-section-theme": "^1.
|
|
94
|
+
"pict-section-modal": "^1.1.4",
|
|
95
|
+
"pict-section-theme": "^1.1.1",
|
|
96
96
|
"pict-service-commandlineutility": "^1.0.19",
|
|
97
97
|
"pict-sessionmanager": "^1.0.2",
|
|
98
98
|
"pict-view": "^1.0.68",
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Meadow Delete Cursor Store
|
|
3
|
+
*
|
|
4
|
+
* Tiny JSON-file store for the resumable delete-sync cursor. One file holds a
|
|
5
|
+
* map keyed by table name; each entry is a few numbers (head/tail marks + a
|
|
6
|
+
* caught-up flag + a last-sweep timestamp). Deliberately NOT a database: the
|
|
7
|
+
* payload is trivial, there is a single writer (the headless run is sequential),
|
|
8
|
+
* and a human-readable file is easy to inspect and mount on a volume.
|
|
9
|
+
*
|
|
10
|
+
* The file must live on a path that survives between runs/containers for the
|
|
11
|
+
* cursor to actually advance; if it doesn't, every load returns empty and the
|
|
12
|
+
* delete sync simply falls back to a full sweep (safe, just not incremental).
|
|
13
|
+
*
|
|
14
|
+
* All operations are best-effort and never throw to the caller: a missing or
|
|
15
|
+
* corrupt file reads as "no state" so the sync degrades to a full sweep rather
|
|
16
|
+
* than failing. Writes are atomic (temp file + rename) and merge-preserve other
|
|
17
|
+
* tables' entries.
|
|
18
|
+
*
|
|
19
|
+
* @typedef {Object} DeleteCursorState
|
|
20
|
+
* @property {number} HeadID Highest deleted id already covered from the top (0 = not yet established).
|
|
21
|
+
* @property {?number} TailID Resume point for the downward catch-up sweep (null = sweep from the top).
|
|
22
|
+
* @property {boolean} CaughtUp True once the tail has drained to the bottom of the deleted set.
|
|
23
|
+
* @property {number} LastSweepEpoch Epoch ms when the last full sweep completed (for the re-sweep cadence).
|
|
24
|
+
*/
|
|
25
|
+
const libFS = require('fs');
|
|
26
|
+
const libPath = require('path');
|
|
27
|
+
|
|
28
|
+
class MeadowDeleteCursorStore
|
|
29
|
+
{
|
|
30
|
+
/**
|
|
31
|
+
* @param {string} pStatePath - Filesystem path to the JSON state file.
|
|
32
|
+
* @param {Object} [pLog] - Optional logger ({ warn }) for surfacing write failures.
|
|
33
|
+
*/
|
|
34
|
+
constructor(pStatePath, pLog)
|
|
35
|
+
{
|
|
36
|
+
this.statePath = pStatePath;
|
|
37
|
+
this.log = pLog || null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Read the entire state map. Missing/corrupt file → {} (graceful).
|
|
42
|
+
* @return {Object<string, DeleteCursorState>}
|
|
43
|
+
*/
|
|
44
|
+
readAll()
|
|
45
|
+
{
|
|
46
|
+
try
|
|
47
|
+
{
|
|
48
|
+
const tmpRaw = libFS.readFileSync(this.statePath, 'utf8');
|
|
49
|
+
const tmpParsed = JSON.parse(tmpRaw);
|
|
50
|
+
return (tmpParsed && typeof(tmpParsed) === 'object') ? tmpParsed : {};
|
|
51
|
+
}
|
|
52
|
+
catch (pError)
|
|
53
|
+
{
|
|
54
|
+
// ENOENT (first run) and parse errors both degrade to "no state".
|
|
55
|
+
if (this.log && pError && pError.code !== 'ENOENT')
|
|
56
|
+
{
|
|
57
|
+
this.log.warn(`Delete cursor state unreadable at [${this.statePath}] (${pError.message}); treating as empty (full sweep).`);
|
|
58
|
+
}
|
|
59
|
+
return {};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get one table's cursor state, or null if none recorded.
|
|
65
|
+
* @param {string} pTableName
|
|
66
|
+
* @return {?DeleteCursorState}
|
|
67
|
+
*/
|
|
68
|
+
get(pTableName)
|
|
69
|
+
{
|
|
70
|
+
const tmpAll = this.readAll();
|
|
71
|
+
return Object.prototype.hasOwnProperty.call(tmpAll, pTableName) ? tmpAll[pTableName] : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Persist one table's cursor state, preserving all other tables' entries.
|
|
76
|
+
* Atomic (temp + rename). Returns true on success, false on failure (never throws).
|
|
77
|
+
* @param {string} pTableName
|
|
78
|
+
* @param {DeleteCursorState} pState
|
|
79
|
+
* @return {boolean}
|
|
80
|
+
*/
|
|
81
|
+
set(pTableName, pState)
|
|
82
|
+
{
|
|
83
|
+
try
|
|
84
|
+
{
|
|
85
|
+
const tmpAll = this.readAll();
|
|
86
|
+
tmpAll[pTableName] = pState;
|
|
87
|
+
|
|
88
|
+
libFS.mkdirSync(libPath.dirname(this.statePath), { recursive: true });
|
|
89
|
+
const tmpTempPath = `${this.statePath}.${process.pid}.tmp`;
|
|
90
|
+
libFS.writeFileSync(tmpTempPath, JSON.stringify(tmpAll, null, '\t'), 'utf8');
|
|
91
|
+
libFS.renameSync(tmpTempPath, this.statePath);
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
catch (pError)
|
|
95
|
+
{
|
|
96
|
+
if (this.log)
|
|
97
|
+
{
|
|
98
|
+
this.log.warn(`Could not persist delete cursor for [${pTableName}] at [${this.statePath}] (${pError && pError.message}); progress will not carry to the next run.`);
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = MeadowDeleteCursorStore;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const libMeadowSyncEntityOngoing = require('./Meadow-Service-Sync-Entity-Ongoing.js');
|
|
2
|
+
const libMeadowDeleteCursorStore = require('./Meadow-Service-DeleteCursorStore.js');
|
|
2
3
|
|
|
3
4
|
class MeadowSyncEntityOngoingEventualConsistency extends libMeadowSyncEntityOngoing
|
|
4
5
|
{
|
|
@@ -13,6 +14,19 @@ class MeadowSyncEntityOngoingEventualConsistency extends libMeadowSyncEntityOngo
|
|
|
13
14
|
this.BackSyncTimeLimit = (typeof(this.options.BackSyncTimeLimit) === 'number')
|
|
14
15
|
? this.options.BackSyncTimeLimit
|
|
15
16
|
: 30000;
|
|
17
|
+
|
|
18
|
+
// Optional resumable-delete cursor. When a state-file path is configured
|
|
19
|
+
// (via options or fable settings), delete reconciliation persists how far
|
|
20
|
+
// it has progressed so each run resumes instead of re-walking from the top.
|
|
21
|
+
// Unset → disabled, and delete sync behaves exactly as the non-cursor path.
|
|
22
|
+
// The file must live on a path that survives between runs to be useful.
|
|
23
|
+
const tmpSettings = this.fable.settings || {};
|
|
24
|
+
this.DeleteCursorStatePath = this.options.DeleteCursorStatePath || tmpSettings.DeleteCursorStatePath || '';
|
|
25
|
+
// Hours between full re-sweeps once caught up (catches deletions of older
|
|
26
|
+
// records that landed in already-swept id ranges). Default 1 week.
|
|
27
|
+
this.DeleteResweepIntervalHours = (typeof(this.options.DeleteResweepIntervalHours) === 'number')
|
|
28
|
+
? this.options.DeleteResweepIntervalHours
|
|
29
|
+
: (typeof(tmpSettings.DeleteResweepIntervalHours) === 'number') ? tmpSettings.DeleteResweepIntervalHours : 168;
|
|
16
30
|
}
|
|
17
31
|
|
|
18
32
|
// Bisect an ID range with a time budget. Checks the budget at the start of
|
|
@@ -139,9 +153,29 @@ class MeadowSyncEntityOngoingEventualConsistency extends libMeadowSyncEntityOngo
|
|
|
139
153
|
}
|
|
140
154
|
|
|
141
155
|
// Override deleted record sync with a time-budgeted version.
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
156
|
+
//
|
|
157
|
+
// The server does not return deleted rows through the normal endpoints, so
|
|
158
|
+
// we page the deleted set explicitly and flag the matching LOCAL row deleted.
|
|
159
|
+
//
|
|
160
|
+
// SAFETY — match on IDENTITY, never on GUID. This is a database clone: the
|
|
161
|
+
// identity column is authoritative and equals the origin's (joins and FKs
|
|
162
|
+
// depend on it), while GUIDs are NOT guaranteed unique in this data. Acting
|
|
163
|
+
// on a GUID match could soft-delete the WRONG row (a different, possibly
|
|
164
|
+
// active, record that happens to share the GUID). So we read and delete only
|
|
165
|
+
// by the row whose id equals the server's deleted row's id.
|
|
166
|
+
//
|
|
167
|
+
// A deleted server row whose id is not present locally was deleted before it
|
|
168
|
+
// was ever cloned here. We do NOT create it: with duplicate GUIDs a create
|
|
169
|
+
// collides on the GUID unique index (the old "...already exists!" log storm),
|
|
170
|
+
// and backfilling those rows + de-duplicating GUIDs is a separate cleanup.
|
|
171
|
+
// We count them so the remaining backlog is visible.
|
|
172
|
+
//
|
|
173
|
+
// Ordering: newest-first by the indexed identity column. Sorting by
|
|
174
|
+
// DeleteDate is honored by the API but is an unindexed filesort (10-150x
|
|
175
|
+
// slower on large tables — Observation: 87s for one page), so we order by the
|
|
176
|
+
// PK and stay within budget. DeleteDate is present in the payload, so a
|
|
177
|
+
// future steady-state cursor can track the server's delete high-water mark
|
|
178
|
+
// without paying for the sort. Processing is time-budgeted to BackSyncTimeLimit.
|
|
145
179
|
syncDeletedRecords(fCallback)
|
|
146
180
|
{
|
|
147
181
|
const tmpDeletedColumn = this.EntitySchema.Columns.find((c) => c.Column == 'Deleted');
|
|
@@ -151,7 +185,15 @@ class MeadowSyncEntityOngoingEventualConsistency extends libMeadowSyncEntityOngo
|
|
|
151
185
|
return fCallback();
|
|
152
186
|
}
|
|
153
187
|
|
|
154
|
-
|
|
188
|
+
// Opt-in resumable cursor: when a state-file path is configured, persist
|
|
189
|
+
// how far reconciliation has progressed so each run resumes instead of
|
|
190
|
+
// re-walking from the newest record every time. Unset → this exact path.
|
|
191
|
+
if (this.DeleteCursorStatePath)
|
|
192
|
+
{
|
|
193
|
+
return this._syncDeletedRecordsWithCursor(fCallback);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
this.fable.log.info(`Checking for deleted records on server for ${this.EntitySchema.TableName} (time-budgeted, matching on ${this.DefaultIdentifier})...`);
|
|
155
197
|
|
|
156
198
|
this.fable.MeadowCloneRestClient.getJSON(this._appendDeletedQueryString(`${this.EntitySchema.TableName}s/Count/FilteredTo/FBV~Deleted~EQ~1`),
|
|
157
199
|
(pError, pResponse, pBody) =>
|
|
@@ -169,122 +211,66 @@ class MeadowSyncEntityOngoingEventualConsistency extends libMeadowSyncEntityOngo
|
|
|
169
211
|
return fCallback();
|
|
170
212
|
}
|
|
171
213
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
let tmpDeleteCap = (this.MaxRecordsPerEntity > 0)
|
|
214
|
+
const tmpDeleteCap = (this.MaxRecordsPerEntity > 0)
|
|
175
215
|
? Math.min(tmpDeletedCount, this.MaxRecordsPerEntity)
|
|
176
216
|
: tmpDeletedCount;
|
|
177
217
|
|
|
218
|
+
this.fable.log.info(`Found ${tmpDeletedCount} deleted records on server for ${this.EntitySchema.TableName}; reconciling newest-first with ${this.BackSyncTimeLimit}ms budget...`);
|
|
219
|
+
|
|
178
220
|
const tmpStartTime = Date.now();
|
|
179
|
-
let tmpProcessed = 0;
|
|
180
221
|
let tmpOffset = 0;
|
|
222
|
+
const tmpCounters = { seen: 0, marked: 0, already: 0, notInClone: 0, errors: 0 };
|
|
223
|
+
|
|
224
|
+
const fFinish = (pReason) =>
|
|
225
|
+
{
|
|
226
|
+
const tmpElapsed = Date.now() - tmpStartTime;
|
|
227
|
+
// Surface the real delete count in the structured run report
|
|
228
|
+
// (it was previously hard-coded to 0).
|
|
229
|
+
if (this.syncResults)
|
|
230
|
+
{
|
|
231
|
+
this.syncResults.Deleted = tmpCounters.marked;
|
|
232
|
+
}
|
|
233
|
+
this.fable.log.info(`Delete sync ${pReason} for ${this.EntitySchema.TableName} after ${tmpElapsed}ms: marked ${tmpCounters.marked}, already-deleted ${tmpCounters.already}, not-in-clone ${tmpCounters.notInClone}, errors ${tmpCounters.errors} (examined ${tmpCounters.seen} of ${tmpDeletedCount}).`);
|
|
234
|
+
return fCallback();
|
|
235
|
+
};
|
|
181
236
|
|
|
182
237
|
// Fetch deleted record pages one at a time using a recursive
|
|
183
|
-
// fetcher
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
// and then had to skip them all when the budget expired.
|
|
238
|
+
// fetcher. Newest-first by the indexed identity column so recent
|
|
239
|
+
// deletions are reconciled within budget even when the historical
|
|
240
|
+
// backlog is large.
|
|
187
241
|
const fFetchDeletedPage = () =>
|
|
188
242
|
{
|
|
189
|
-
// Time budget check — stop immediately
|
|
190
243
|
if (Date.now() - tmpStartTime >= this.BackSyncTimeLimit)
|
|
191
244
|
{
|
|
192
|
-
|
|
193
|
-
this.fable.log.info(`Delete sync time budget exhausted for ${this.EntitySchema.TableName} after ${tmpElapsed}ms (${tmpProcessed} of ${tmpDeletedCount} deleted records processed).`);
|
|
194
|
-
return fCallback();
|
|
245
|
+
return fFinish('time budget exhausted');
|
|
195
246
|
}
|
|
196
|
-
|
|
197
|
-
// All pages processed
|
|
198
247
|
if (tmpOffset >= tmpDeleteCap)
|
|
199
248
|
{
|
|
200
|
-
|
|
201
|
-
this.fable.log.info(`Delete sync complete for ${this.EntitySchema.TableName} (${tmpProcessed} of ${tmpDeletedCount} deleted records processed in ${tmpElapsed}ms).`);
|
|
202
|
-
return fCallback();
|
|
249
|
+
return fFinish('complete');
|
|
203
250
|
}
|
|
204
251
|
|
|
205
|
-
const tmpURL = this._appendDeletedQueryString(`${this.EntitySchema.TableName}s/FilteredTo/FBV~Deleted~EQ~1~FSF~${this.DefaultIdentifier}~
|
|
252
|
+
const tmpURL = this._appendDeletedQueryString(`${this.EntitySchema.TableName}s/FilteredTo/FBV~Deleted~EQ~1~FSF~${this.DefaultIdentifier}~DESC~DESC/${tmpOffset}/${this.PageSize}`);
|
|
206
253
|
tmpOffset += this.PageSize;
|
|
207
254
|
|
|
208
255
|
this.fable.MeadowCloneRestClient.getJSON(tmpURL,
|
|
209
|
-
(pDownloadError, pResponse,
|
|
256
|
+
(pDownloadError, pResponse, pPageBody) =>
|
|
210
257
|
{
|
|
211
|
-
if (pDownloadError || !
|
|
258
|
+
if (pDownloadError || !pPageBody || !Array.isArray(pPageBody) || pPageBody.length < 1)
|
|
212
259
|
{
|
|
213
|
-
|
|
214
|
-
const tmpElapsed = Date.now() - tmpStartTime;
|
|
215
|
-
this.fable.log.info(`Delete sync complete for ${this.EntitySchema.TableName} (${tmpProcessed} of ${tmpDeletedCount} deleted records processed in ${tmpElapsed}ms).`);
|
|
216
|
-
return fCallback();
|
|
260
|
+
return fFinish('complete');
|
|
217
261
|
}
|
|
218
262
|
|
|
219
|
-
this.fable.Utility.eachLimit(
|
|
263
|
+
this.fable.Utility.eachLimit(pPageBody, 5,
|
|
220
264
|
(pEntityRecord, fRecordComplete) =>
|
|
221
265
|
{
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
{
|
|
225
|
-
return setImmediate(fRecordComplete);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
const tmpQuery = this.Meadow.query;
|
|
229
|
-
tmpQuery.addFilter(this.DefaultIdentifier, tmpRecordID);
|
|
230
|
-
tmpQuery.setDisableDeleteTracking(true);
|
|
231
|
-
|
|
232
|
-
this.Meadow.doRead(tmpQuery,
|
|
233
|
-
(pReadError, pQuery, pRecord) =>
|
|
234
|
-
{
|
|
235
|
-
if (pReadError || !pRecord)
|
|
236
|
-
{
|
|
237
|
-
const tmpRecordToCommit = this.marshalRecord(pEntityRecord);
|
|
238
|
-
|
|
239
|
-
const tmpCreateQuery = this.Meadow.query.addRecord(tmpRecordToCommit);
|
|
240
|
-
tmpCreateQuery.setDisableAutoIdentity(true);
|
|
241
|
-
tmpCreateQuery.setDisableAutoDateStamp(true);
|
|
242
|
-
tmpCreateQuery.setDisableAutoUserStamp(true);
|
|
243
|
-
tmpCreateQuery.setDisableDeleteTracking(true);
|
|
244
|
-
tmpCreateQuery.AllowIdentityInsert = true;
|
|
245
|
-
|
|
246
|
-
this.Meadow.doCreate(tmpCreateQuery,
|
|
247
|
-
(pCreateError) =>
|
|
248
|
-
{
|
|
249
|
-
if (pCreateError)
|
|
250
|
-
{
|
|
251
|
-
this.log.error(`Error creating deleted record ${this.EntitySchema.TableName} ID ${tmpRecordID}: ${pCreateError}`);
|
|
252
|
-
}
|
|
253
|
-
tmpProcessed++;
|
|
254
|
-
return setImmediate(fRecordComplete);
|
|
255
|
-
});
|
|
256
|
-
return;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
if (pRecord.Deleted == 1)
|
|
260
|
-
{
|
|
261
|
-
tmpProcessed++;
|
|
262
|
-
return setImmediate(fRecordComplete);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
const tmpRecordToCommit = this.marshalRecord(pEntityRecord);
|
|
266
|
-
|
|
267
|
-
const tmpUpdateQuery = this.Meadow.query.addRecord(tmpRecordToCommit);
|
|
268
|
-
tmpUpdateQuery.setDisableAutoIdentity(true);
|
|
269
|
-
tmpUpdateQuery.setDisableAutoDateStamp(true);
|
|
270
|
-
tmpUpdateQuery.setDisableAutoUserStamp(true);
|
|
271
|
-
tmpUpdateQuery.setDisableDeleteTracking(true);
|
|
272
|
-
|
|
273
|
-
this.Meadow.doUpdate(tmpUpdateQuery,
|
|
274
|
-
(pUpdateError) =>
|
|
275
|
-
{
|
|
276
|
-
if (pUpdateError)
|
|
277
|
-
{
|
|
278
|
-
this.log.error(`Error marking record as deleted ${this.EntitySchema.TableName} ID ${tmpRecordID}: ${pUpdateError}`);
|
|
279
|
-
}
|
|
280
|
-
tmpProcessed++;
|
|
281
|
-
return setImmediate(fRecordComplete);
|
|
282
|
-
});
|
|
283
|
-
});
|
|
266
|
+
tmpCounters.seen++;
|
|
267
|
+
this._reconcileDeletedRecordByID(pEntityRecord, tmpCounters, fRecordComplete);
|
|
284
268
|
},
|
|
285
269
|
(pRecordSyncError) =>
|
|
286
270
|
{
|
|
287
|
-
// Page complete — fetch next page
|
|
271
|
+
// Page complete — heartbeat, then fetch next page.
|
|
272
|
+
const tmpElapsed = Date.now() - tmpStartTime;
|
|
273
|
+
this.fable.log.info(`Delete sync ${this.EntitySchema.TableName}: examined ${tmpCounters.seen}/${tmpDeletedCount} — marked ${tmpCounters.marked}, already ${tmpCounters.already}, not-in-clone ${tmpCounters.notInClone}, errors ${tmpCounters.errors} (${tmpElapsed}ms).`);
|
|
288
274
|
return setImmediate(fFetchDeletedPage);
|
|
289
275
|
});
|
|
290
276
|
});
|
|
@@ -294,6 +280,255 @@ class MeadowSyncEntityOngoingEventualConsistency extends libMeadowSyncEntityOngo
|
|
|
294
280
|
});
|
|
295
281
|
}
|
|
296
282
|
|
|
283
|
+
// Reconcile one server-deleted record into the clone, matched by IDENTITY
|
|
284
|
+
// ONLY (never GUID — see the SAFETY note on syncDeletedRecords). Increments
|
|
285
|
+
// exactly one counter on pCounters: marked / already / notInClone / errors.
|
|
286
|
+
// Shared by both the time-budgeted path and the resumable-cursor path.
|
|
287
|
+
_reconcileDeletedRecordByID(pEntityRecord, pCounters, fDone)
|
|
288
|
+
{
|
|
289
|
+
const tmpRecordID = pEntityRecord[this.DefaultIdentifier];
|
|
290
|
+
if (tmpRecordID === undefined || tmpRecordID === null || tmpRecordID < 1)
|
|
291
|
+
{
|
|
292
|
+
pCounters.notInClone++;
|
|
293
|
+
return setImmediate(fDone);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Read by the authoritative, unique identity column. Delete tracking is
|
|
297
|
+
// disabled on the read so an already-deleted local row is still found
|
|
298
|
+
// (and skipped) rather than re-attempted.
|
|
299
|
+
const tmpQuery = this.Meadow.query;
|
|
300
|
+
tmpQuery.addFilter(this.DefaultIdentifier, tmpRecordID);
|
|
301
|
+
tmpQuery.setDisableDeleteTracking(true);
|
|
302
|
+
|
|
303
|
+
this.Meadow.doRead(tmpQuery,
|
|
304
|
+
(pReadError, pReadQuery, pLocalRecord) =>
|
|
305
|
+
{
|
|
306
|
+
if (pReadError)
|
|
307
|
+
{
|
|
308
|
+
pCounters.errors++;
|
|
309
|
+
this.log.error(`Delete sync read error for ${this.EntitySchema.TableName} ${this.DefaultIdentifier}=${tmpRecordID}: ${pReadError}`);
|
|
310
|
+
return setImmediate(fDone);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Deleted id not in the clone — deleted before it was ever synced
|
|
314
|
+
// here. Do NOT create (collides on duplicate GUIDs); leave it for
|
|
315
|
+
// the backfill/de-dup cleanup.
|
|
316
|
+
if (!pLocalRecord)
|
|
317
|
+
{
|
|
318
|
+
pCounters.notInClone++;
|
|
319
|
+
return setImmediate(fDone);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Already reconciled — cheap skip.
|
|
323
|
+
if (pLocalRecord.Deleted == 1)
|
|
324
|
+
{
|
|
325
|
+
pCounters.already++;
|
|
326
|
+
return setImmediate(fDone);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Flag THIS row — selected by its authoritative, unique id, so we
|
|
330
|
+
// can never touch a different record that shares the GUID. doDelete
|
|
331
|
+
// is the canonical soft-delete (UPDATE ... SET Deleted=1,
|
|
332
|
+
// DeleteDate=NOW(), DeletingIDUser=... WHERE id=? AND Deleted=0):
|
|
333
|
+
// idempotent, and — unlike doUpdate — it neither strips the
|
|
334
|
+
// delete-tracking columns nor trips the delete-tracking-filtered
|
|
335
|
+
// post-update verify read. DeleteDate is the local detection time
|
|
336
|
+
// (meadow has no path to set the source's value); fine for the clone.
|
|
337
|
+
const tmpDeleteQuery = this.Meadow.query;
|
|
338
|
+
tmpDeleteQuery.addFilter(this.DefaultIdentifier, tmpRecordID);
|
|
339
|
+
|
|
340
|
+
this.Meadow.doDelete(tmpDeleteQuery,
|
|
341
|
+
(pDeleteError) =>
|
|
342
|
+
{
|
|
343
|
+
if (pDeleteError)
|
|
344
|
+
{
|
|
345
|
+
pCounters.errors++;
|
|
346
|
+
this.log.error(`Error marking record deleted ${this.EntitySchema.TableName} ${this.DefaultIdentifier}=${tmpRecordID}: ${pDeleteError}`);
|
|
347
|
+
}
|
|
348
|
+
else
|
|
349
|
+
{
|
|
350
|
+
pCounters.marked++;
|
|
351
|
+
}
|
|
352
|
+
return setImmediate(fDone);
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Resumable delete reconciliation (opt-in via DeleteCursorStatePath).
|
|
358
|
+
//
|
|
359
|
+
// Persists two id marks per table in a small JSON file so a run resumes
|
|
360
|
+
// rather than re-walking from the newest record (which, with heavy rows and a
|
|
361
|
+
// time budget, never reaches the older backlog):
|
|
362
|
+
// - HeadID: highest deleted id already covered from the top.
|
|
363
|
+
// - TailID: resume point of the downward catch-up sweep.
|
|
364
|
+
// Each run does a HEAD pass (id > HeadID — new deletions since last run, cheap)
|
|
365
|
+
// then a TAIL pass (id < TailID — drain older backlog within the remaining
|
|
366
|
+
// budget). When the tail reaches the bottom, the backlog is drained and only
|
|
367
|
+
// the head pass runs thereafter. A periodic re-sweep (DeleteResweepIntervalHours)
|
|
368
|
+
// resets the tail to catch deletions that landed in already-swept id ranges.
|
|
369
|
+
// All keyset paging (id < cursor) — no growing OFFSET scan.
|
|
370
|
+
//
|
|
371
|
+
// Safety/behavior is identical to the non-cursor path (same id-only
|
|
372
|
+
// _reconcileDeletedRecordByID); only WHERE paging starts differs. Missing or
|
|
373
|
+
// unreadable state degrades to a full sweep.
|
|
374
|
+
_syncDeletedRecordsWithCursor(fCallback)
|
|
375
|
+
{
|
|
376
|
+
const tmpTable = this.EntitySchema.TableName;
|
|
377
|
+
const tmpStore = new libMeadowDeleteCursorStore(this.DeleteCursorStatePath, this.fable.log);
|
|
378
|
+
const tmpState = tmpStore.get(tmpTable) || { HeadID: 0, TailID: null, CaughtUp: false, LastSweepEpoch: 0 };
|
|
379
|
+
|
|
380
|
+
// Re-sweep: once caught up, periodically reset the tail to re-drain from
|
|
381
|
+
// the top so deletions that landed in already-swept ranges are caught.
|
|
382
|
+
const tmpNow = Date.now();
|
|
383
|
+
if (tmpState.CaughtUp && this.DeleteResweepIntervalHours > 0
|
|
384
|
+
&& (tmpNow - (tmpState.LastSweepEpoch || 0)) >= (this.DeleteResweepIntervalHours * 3600000))
|
|
385
|
+
{
|
|
386
|
+
this.fable.log.info(`Delete cursor for ${tmpTable}: re-sweep interval elapsed; resetting tail to re-drain from the top.`);
|
|
387
|
+
tmpState.TailID = null;
|
|
388
|
+
tmpState.CaughtUp = false;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
this.fable.log.info(`Delete cursor for ${tmpTable}: headID=${tmpState.HeadID}, tailID=${tmpState.TailID === null ? 'top' : tmpState.TailID}, caughtUp=${tmpState.CaughtUp} (matching on ${this.DefaultIdentifier}, ${this.BackSyncTimeLimit}ms budget)...`);
|
|
392
|
+
|
|
393
|
+
const tmpStartTime = Date.now();
|
|
394
|
+
const tmpCounters = { seen: 0, marked: 0, already: 0, notInClone: 0, errors: 0 };
|
|
395
|
+
let tmpNewHeadID = tmpState.HeadID;
|
|
396
|
+
|
|
397
|
+
const fSaveAndFinish = (pReason) =>
|
|
398
|
+
{
|
|
399
|
+
tmpState.HeadID = tmpNewHeadID;
|
|
400
|
+
tmpStore.set(tmpTable, tmpState);
|
|
401
|
+
const tmpElapsed = Date.now() - tmpStartTime;
|
|
402
|
+
if (this.syncResults)
|
|
403
|
+
{
|
|
404
|
+
this.syncResults.Deleted = tmpCounters.marked;
|
|
405
|
+
}
|
|
406
|
+
this.fable.log.info(`Delete cursor ${pReason} for ${tmpTable} after ${tmpElapsed}ms: marked ${tmpCounters.marked}, already ${tmpCounters.already}, not-in-clone ${tmpCounters.notInClone}, errors ${tmpCounters.errors} (examined ${tmpCounters.seen}); headID=${tmpState.HeadID}, tailID=${tmpState.TailID === null ? 'top' : tmpState.TailID}, caughtUp=${tmpState.CaughtUp}.`);
|
|
407
|
+
return fCallback();
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
// HEAD pass — only once a head has been established (not the first run).
|
|
411
|
+
const fRunHeadPass = (fHeadDone) =>
|
|
412
|
+
{
|
|
413
|
+
if (!(tmpState.HeadID > 0))
|
|
414
|
+
{
|
|
415
|
+
return fHeadDone();
|
|
416
|
+
}
|
|
417
|
+
this._fetchDeletedKeysetPass({ floorID: tmpState.HeadID, ceilID: null, startTime: tmpStartTime, counters: tmpCounters, label: 'head' },
|
|
418
|
+
(pResult) =>
|
|
419
|
+
{
|
|
420
|
+
if (pResult.maxID !== null && pResult.maxID > tmpNewHeadID) { tmpNewHeadID = pResult.maxID; }
|
|
421
|
+
return fHeadDone();
|
|
422
|
+
});
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
// TAIL pass — drain downward from TailID (null = from the very top, which
|
|
426
|
+
// is the first run and also establishes the head).
|
|
427
|
+
const fRunTailPass = (fTailDone) =>
|
|
428
|
+
{
|
|
429
|
+
if (tmpState.CaughtUp)
|
|
430
|
+
{
|
|
431
|
+
return fTailDone();
|
|
432
|
+
}
|
|
433
|
+
this._fetchDeletedKeysetPass({ floorID: null, ceilID: tmpState.TailID, startTime: tmpStartTime, counters: tmpCounters, label: 'tail' },
|
|
434
|
+
(pResult) =>
|
|
435
|
+
{
|
|
436
|
+
if (pResult.maxID !== null && pResult.maxID > tmpNewHeadID) { tmpNewHeadID = pResult.maxID; }
|
|
437
|
+
if (pResult.minID !== null) { tmpState.TailID = pResult.minID; }
|
|
438
|
+
if (pResult.reachedEnd)
|
|
439
|
+
{
|
|
440
|
+
tmpState.CaughtUp = true;
|
|
441
|
+
tmpState.LastSweepEpoch = Date.now();
|
|
442
|
+
}
|
|
443
|
+
return fTailDone();
|
|
444
|
+
});
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
fRunHeadPass(() =>
|
|
448
|
+
{
|
|
449
|
+
fRunTailPass(() =>
|
|
450
|
+
{
|
|
451
|
+
return fSaveAndFinish(tmpState.CaughtUp ? 'caught up (steady state)' : 'progressed');
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Keyset-paged pass over the deleted set: Deleted=1 [AND id > floorID]
|
|
457
|
+
// [AND id < ceilID], ordered id DESC, advancing the ceiling to each page's
|
|
458
|
+
// lowest id. Shares the run's time budget (BackSyncTimeLimit) and the global
|
|
459
|
+
// MaxRecordsPerEntity cap. Calls fComplete({ maxID, minID, reachedEnd }):
|
|
460
|
+
// maxID = highest id seen (first row, establishes head on the first run),
|
|
461
|
+
// minID = lowest id seen (next resume point), reachedEnd = the deleted set was
|
|
462
|
+
// exhausted within this pass.
|
|
463
|
+
_fetchDeletedKeysetPass(pOptions, fComplete)
|
|
464
|
+
{
|
|
465
|
+
const tmpFloorID = (pOptions.floorID === undefined) ? null : pOptions.floorID;
|
|
466
|
+
let tmpCeilID = (pOptions.ceilID === undefined) ? null : pOptions.ceilID;
|
|
467
|
+
const tmpCounters = pOptions.counters;
|
|
468
|
+
let tmpMaxID = null;
|
|
469
|
+
let tmpMinID = null;
|
|
470
|
+
|
|
471
|
+
const fFetch = () =>
|
|
472
|
+
{
|
|
473
|
+
if (Date.now() - pOptions.startTime >= this.BackSyncTimeLimit)
|
|
474
|
+
{
|
|
475
|
+
return fComplete({ maxID: tmpMaxID, minID: tmpMinID, reachedEnd: false });
|
|
476
|
+
}
|
|
477
|
+
if (this.MaxRecordsPerEntity > 0 && tmpCounters.seen >= this.MaxRecordsPerEntity)
|
|
478
|
+
{
|
|
479
|
+
return fComplete({ maxID: tmpMaxID, minID: tmpMinID, reachedEnd: false });
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
let tmpFilter = 'FBV~Deleted~EQ~1';
|
|
483
|
+
if (tmpFloorID !== null) { tmpFilter += `~FBV~${this.DefaultIdentifier}~GT~${tmpFloorID}`; }
|
|
484
|
+
if (tmpCeilID !== null) { tmpFilter += `~FBV~${this.DefaultIdentifier}~LT~${tmpCeilID}`; }
|
|
485
|
+
tmpFilter += `~FSF~${this.DefaultIdentifier}~DESC~DESC`;
|
|
486
|
+
const tmpURL = this._appendDeletedQueryString(`${this.EntitySchema.TableName}s/FilteredTo/${tmpFilter}/0/${this.PageSize}`);
|
|
487
|
+
|
|
488
|
+
this.fable.MeadowCloneRestClient.getJSON(tmpURL,
|
|
489
|
+
(pError, pResponse, pPageBody) =>
|
|
490
|
+
{
|
|
491
|
+
if (pError)
|
|
492
|
+
{
|
|
493
|
+
this.fable.log.warn(`Delete cursor ${this.EntitySchema.TableName} [${pOptions.label}]: page fetch error (${pError}); pausing pass.`);
|
|
494
|
+
return fComplete({ maxID: tmpMaxID, minID: tmpMinID, reachedEnd: false });
|
|
495
|
+
}
|
|
496
|
+
if (!Array.isArray(pPageBody) || pPageBody.length < 1)
|
|
497
|
+
{
|
|
498
|
+
// Empty page = exhausted this id range.
|
|
499
|
+
return fComplete({ maxID: tmpMaxID, minID: tmpMinID, reachedEnd: true });
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (tmpMaxID === null)
|
|
503
|
+
{
|
|
504
|
+
tmpMaxID = pPageBody[0][this.DefaultIdentifier]; // DESC → first row is the highest id
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
this.fable.Utility.eachLimit(pPageBody, 5,
|
|
508
|
+
(pEntityRecord, fRecordComplete) =>
|
|
509
|
+
{
|
|
510
|
+
tmpCounters.seen++;
|
|
511
|
+
this._reconcileDeletedRecordByID(pEntityRecord, tmpCounters, fRecordComplete);
|
|
512
|
+
},
|
|
513
|
+
(pRecordSyncError) =>
|
|
514
|
+
{
|
|
515
|
+
const tmpPageMinID = pPageBody[pPageBody.length - 1][this.DefaultIdentifier];
|
|
516
|
+
tmpMinID = tmpPageMinID;
|
|
517
|
+
tmpCeilID = tmpPageMinID; // keyset: next page is strictly below this page's lowest id
|
|
518
|
+
const tmpElapsed = Date.now() - pOptions.startTime;
|
|
519
|
+
this.fable.log.info(`Delete cursor ${this.EntitySchema.TableName} [${pOptions.label}]: examined ${tmpCounters.seen} — marked ${tmpCounters.marked}, already ${tmpCounters.already}, not-in-clone ${tmpCounters.notInClone}, errors ${tmpCounters.errors}; at id ${tmpPageMinID} (${tmpElapsed}ms).`);
|
|
520
|
+
if (pPageBody.length < this.PageSize)
|
|
521
|
+
{
|
|
522
|
+
return fComplete({ maxID: tmpMaxID, minID: tmpMinID, reachedEnd: true });
|
|
523
|
+
}
|
|
524
|
+
return setImmediate(fFetch);
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
fFetch();
|
|
530
|
+
}
|
|
531
|
+
|
|
297
532
|
_syncInternal(fCallback)
|
|
298
533
|
{
|
|
299
534
|
this.operation.createTimeStamp('EntityOngoingEventualConsistencySync');
|
|
@@ -184,6 +184,8 @@ class MeadowSync extends libFableServiceProviderBase
|
|
|
184
184
|
UseAdvancedIDPagination: this.UseAdvancedIDPagination,
|
|
185
185
|
BackSyncTimeLimit: this.BackSyncTimeLimit,
|
|
186
186
|
TrueUpPageSize: this.TrueUpPageSize,
|
|
187
|
+
DeleteCursorStatePath: this.options.DeleteCursorStatePath,
|
|
188
|
+
DeleteResweepIntervalHours: this.options.DeleteResweepIntervalHours,
|
|
187
189
|
};
|
|
188
190
|
|
|
189
191
|
// Apply per-entity option overrides if configured
|
|
@@ -428,18 +428,28 @@ function setupSQLiteProvider(pFable, fCallback)
|
|
|
428
428
|
|
|
429
429
|
function seedLocalBooks(pFable, pBooks)
|
|
430
430
|
{
|
|
431
|
-
|
|
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
|
-
|
|
439
|
+
tmpDB.exec('BEGIN');
|
|
440
|
+
try
|
|
436
441
|
{
|
|
437
|
-
for (const tmpRecord of
|
|
442
|
+
for (const tmpRecord of pBooks)
|
|
438
443
|
{
|
|
439
444
|
tmpInsert.run(tmpRecord);
|
|
440
445
|
}
|
|
441
|
-
|
|
442
|
-
|
|
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)
|