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.
Files changed (56) hide show
  1. package/BUILDING-AND-PUBLISHING.md +2 -2
  2. package/README.md +27 -8
  3. package/docs/README.md +1 -1
  4. package/docs/_cover.md +1 -1
  5. package/docs/_topbar.md +1 -1
  6. package/docs/_version.json +3 -3
  7. package/docs/architecture.md +14 -225
  8. package/docs/css/docuserve.css +327 -0
  9. package/docs/data-clone/diagrams/architecture-diagram.excalidraw +1756 -0
  10. package/docs/data-clone/diagrams/architecture-diagram.mmd +8 -0
  11. package/docs/data-clone/diagrams/architecture-diagram.svg +2 -0
  12. package/docs/data-clone/overview.md +2 -32
  13. package/docs/diagrams/configuration-cascade-2.excalidraw +831 -0
  14. package/docs/diagrams/configuration-cascade-2.mmd +8 -0
  15. package/docs/diagrams/configuration-cascade-2.svg +2 -0
  16. package/docs/diagrams/configuration-cascade.excalidraw +831 -0
  17. package/docs/diagrams/configuration-cascade.mmd +8 -0
  18. package/docs/diagrams/configuration-cascade.svg +2 -0
  19. package/docs/diagrams/data-synchronization-pipeline.excalidraw +3278 -0
  20. package/docs/diagrams/data-synchronization-pipeline.mmd +42 -0
  21. package/docs/diagrams/data-synchronization-pipeline.svg +2 -0
  22. package/docs/diagrams/data-transformation-pipeline.excalidraw +2929 -0
  23. package/docs/diagrams/data-transformation-pipeline.mmd +31 -0
  24. package/docs/diagrams/data-transformation-pipeline.svg +2 -0
  25. package/docs/diagrams/docker-deployment.excalidraw +1963 -0
  26. package/docs/diagrams/docker-deployment.mmd +23 -0
  27. package/docs/diagrams/docker-deployment.svg +2 -0
  28. package/docs/diagrams/high-level-system-architecture.excalidraw +5752 -0
  29. package/docs/diagrams/high-level-system-architecture.mmd +66 -0
  30. package/docs/diagrams/high-level-system-architecture.svg +2 -0
  31. package/docs/diagrams/module-structure.excalidraw +15206 -0
  32. package/docs/diagrams/module-structure.mmd +56 -0
  33. package/docs/diagrams/module-structure.svg +2 -0
  34. package/docs/diagrams/sync-mode-comparison.excalidraw +3660 -0
  35. package/docs/diagrams/sync-mode-comparison.mmd +33 -0
  36. package/docs/diagrams/sync-mode-comparison.svg +2 -0
  37. package/docs/implementation-reference.md +2 -58
  38. package/docs/index.html +2 -2
  39. package/docs/retold-catalog.json +388 -284
  40. package/docs/retold-keyword-index.json +24830 -16291
  41. package/example-applications/mapping-demo/README.md +2 -10
  42. package/example-applications/mapping-demo/diagrams/architecture.excalidraw +1866 -0
  43. package/example-applications/mapping-demo/diagrams/architecture.mmd +8 -0
  44. package/example-applications/mapping-demo/diagrams/architecture.svg +2 -0
  45. package/example-applications/mapping-demo/package.json +0 -3
  46. package/example-applications/mapping-demo/web/mapping-demo-editor.js +5 -5
  47. package/example-applications/mapping-demo/web/mapping-demo-editor.min.js +2 -2
  48. package/example-applications/mapping-demo/web/pict.min.js +2 -2
  49. package/package.json +7 -7
  50. package/source/services/clone/Meadow-Service-DeleteCursorStore.js +105 -0
  51. package/source/services/clone/Meadow-Service-Sync-Entity-OngoingEventualConsistency.js +327 -92
  52. package/source/services/clone/Meadow-Service-Sync.js +2 -0
  53. package/test/Meadow-Integration-BisectionSync_test.js +15 -5
  54. package/test/Meadow-Integration-NewStrategies_test.js +15 -5
  55. package/test/Meadow-Integration-OngoingEventualConsistencyDeleteCursor_test.js +228 -0
  56. 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.41",
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.3.2",
63
- "quackage": "^1.2.3"
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.41",
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.0.1",
92
+ "pict-provider-theme": "^1.1.2",
93
93
  "pict-section-flow": "^1.0.1",
94
- "pict-section-modal": "^1.1.1",
95
- "pict-section-theme": "^1.0.5",
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
- // The base syncDeletedRecords() walks ALL deleted records on the server
143
- // with no limit, which defeats the purpose of eventual consistency.
144
- // This version processes pages until BackSyncTimeLimit is exhausted.
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
- this.fable.log.info(`Checking for deleted records on server for ${this.EntitySchema.TableName} (time-budgeted)...`);
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
- this.fable.log.info(`Found ${tmpDeletedCount} deleted records on server for ${this.EntitySchema.TableName}; syncing deletions with ${this.BackSyncTimeLimit}ms budget...`);
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 instead of pre-generating all URL partials. With
184
- // millions of deleted records and a short time budget, the old
185
- // eachLimit approach generated hundreds of thousands of partials
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
- const tmpElapsed = Date.now() - tmpStartTime;
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
- const tmpElapsed = Date.now() - tmpStartTime;
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}~ASC~ASC/${tmpOffset}/${this.PageSize}`);
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, pBody) =>
256
+ (pDownloadError, pResponse, pPageBody) =>
210
257
  {
211
- if (pDownloadError || !pBody || !Array.isArray(pBody) || pBody.length < 1)
258
+ if (pDownloadError || !pPageBody || !Array.isArray(pPageBody) || pPageBody.length < 1)
212
259
  {
213
- // Empty page or error — we've reached the end
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(pBody, 5,
263
+ this.fable.Utility.eachLimit(pPageBody, 5,
220
264
  (pEntityRecord, fRecordComplete) =>
221
265
  {
222
- const tmpRecordID = pEntityRecord[this.DefaultIdentifier];
223
- if (!tmpRecordID || tmpRecordID < 1)
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
- 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)