@mindline/sync 1.0.108 → 1.0.110

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindline/sync",
3
- "version": "1.0.108",
3
+ "version": "1.0.110",
4
4
  "description": "sync is a node.js package encapsulating JavaScript classes required for configuring Mindline sync service.",
5
5
  "main": "dist/sync.es.js",
6
6
  "types": "dist/index.d.ts",
package/src/index.d.ts CHANGED
@@ -251,7 +251,6 @@ declare module "@mindline/sync" {
251
251
  tenantNodes: TenantNode[];
252
252
  pb_startTS: number;
253
253
  pb_progress: number;
254
- pb_increment: number;
255
254
  pb_idle: number;
256
255
  pb_idleMax: number;
257
256
  pb_total: number;
@@ -268,12 +267,18 @@ declare module "@mindline/sync" {
268
267
  batchIdArray: Array<Object>,
269
268
  setRefreshDeltaTrigger: (workspace: string) => void,
270
269
  setReadersTotal: (readersTotal: number) => void,
270
+ setReadersExcluded: (readersExcluded: number) => void,
271
271
  setReadersCurrent: (readersCurrent: number) => void,
272
272
  setWritersTotal: (writersTotal: number) => void,
273
+ setWritersExcluded: (writersExcluded: number) => void,
273
274
  setWritersCurrent: (writersCurrent: number) => void,
274
275
  setMilestones: (milestones: Milestone[]) => void,
275
276
  setConfigSyncResult: (result: string) => void,
276
- bClearLocalStorage: boolean): void;
277
+ setSyncProgress: (progress: number) => void,
278
+ bClearLocalStorage: boolean,
279
+ message: string,
280
+ instance?: IPublicClientApplication,
281
+ authorizedUser?: User): void;
277
282
  startSync(instance: IPublicClientApplication, authorizedUser: User | null | undefined, config: SyncConfig | null | undefined): APIResult;
278
283
  }
279
284
  export class TenantNode {
@@ -359,4 +364,4 @@ declare module "@mindline/sync" {
359
364
  // ======================= Azure REST API ===============================
360
365
  export function canListRootAssignments(instance: IPublicClientApplication, user: User): Promise<boolean>;
361
366
  export function elevateGlobalAdminToUserAccessAdmin(instance: IPublicClientApplication, user: User): Promise<boolean>;
362
- }
367
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  //index.ts - published interface - AAD implementations, facade to Mindline SyncConfig API
2
- import * as signalR from "@microsoft/signalr"
3
- import { AccountInfo } from "@azure/msal-common";
2
+ import type { AccountInfo } from "@azure/msal-common";
4
3
  import { IPublicClientApplication, AuthenticationResult } from "@azure/msal-browser"
5
4
  import { deserializeArray } from 'class-transformer';
6
5
  import users from "./users.json";
@@ -918,11 +917,31 @@ export class BatchArray {
918
917
  tenantNodes: TenantNode[];
919
918
  pb_startTS: number;
920
919
  pb_progress: number;
921
- pb_increment: number;
922
920
  pb_idle: number;
923
- pb_idleMax: number;
924
921
  pb_total: number;
925
- pb_timer: NodeJS.Timeout | null;
922
+ pb_timer: ReturnType<typeof setInterval> | null;
923
+
924
+ // Polling: /api/stats is the single source of truth for hydration.
925
+ private readonly pollIntervalSeconds: number = 5;
926
+ private pollTimer: ReturnType<typeof setInterval> | null = null;
927
+ private pollLastUpdatedByBatchId: Record<string, string> = {};
928
+ private pollInstance: IPublicClientApplication | null = null;
929
+ private pollAuthorizedUser: User | null = null;
930
+ private pollBatchIdArray: Array<any> = [];
931
+
932
+ // Last known backend queues (strongest completion signal)
933
+ private lastQueues: { main: number | null; writer: number | null; deferred: number | null } = {
934
+ main: null,
935
+ writer: null,
936
+ deferred: null
937
+ };
938
+ private hasQueueInfo: boolean = false;
939
+
940
+ // Store UI callbacks so we can update the UI from poll/completion events.
941
+ private setIdleText: ((idleText: string) => void) | null = null;
942
+
943
+ // Handler used by polling: we reuse the same stats-processing logic.
944
+ private statsHydrationHandler: ((batchId: string, stats: any) => void) | null = null;
926
945
  milestoneArray: MilestoneArray;
927
946
  constructor(
928
947
  config: SyncConfig | null,
@@ -933,13 +952,18 @@ export class BatchArray {
933
952
  this.init(config, syncPortalGlobalState, bClearLocalStorage);
934
953
  this.pb_startTS = 0;
935
954
  this.pb_progress = 0;
936
- this.pb_increment = 0;
937
955
  this.pb_timer = null;
938
956
  this.pb_idle = 0;
939
- this.pb_idleMax = 0;
940
957
  this.pb_total = 0;
941
958
  this.milestoneArray = new MilestoneArray(false);
942
959
  }
960
+ clearStoredBatchIds(): void {
961
+ if (storageAvailable()) {
962
+ localStorage.setItem("BatchIdArray", "[]");
963
+ // Also clear any persisted UI progress for the in-flight batch restore.
964
+ localStorage.removeItem("BatchIdArrayProgress");
965
+ }
966
+ }
943
967
  // populate tenantNodes based on config tenants
944
968
  init(
945
969
  config: SyncConfig | null | undefined,
@@ -1021,58 +1045,141 @@ export class BatchArray {
1021
1045
  });
1022
1046
  }
1023
1047
  }
1024
- initializeProgressBar(setSyncProgress: (progress: number) => void, setConfigSyncResult: (result: string) => void, setIdleText: (idleText: string) => void, setMilestones: (milestones: Milestone[]) => void): void {
1048
+ initializeProgressBar(setSyncProgress: (progress: number) => void, _setConfigSyncResult: (result: string) => void, setIdleText: (idleText: string) => void, setMilestones: (milestones: Milestone[]) => void): void {
1049
+ // Save callback so completion/polling can update the idle text too.
1050
+ this.setIdleText = setIdleText;
1051
+
1052
+ // IMPORTANT: reset completion signals between sync runs.
1053
+ // Otherwise a previous run that ended with queues=0 could make the next run immediately "Complete".
1054
+ this.hasQueueInfo = false;
1055
+ this.lastQueues = { main: null, writer: null, deferred: null };
1056
+
1025
1057
  this.pb_startTS = Date.now();
1026
1058
  this.pb_progress = 0;
1027
- this.pb_increment = .25;
1028
1059
  this.pb_idle = 0;
1029
- this.pb_idleMax = 0;
1030
1060
  this.pb_total = 0;
1061
+ setSyncProgress(this.pb_progress);
1062
+ setIdleText("Starting sync...");
1031
1063
  this.pb_timer = setInterval(() => {
1032
- // if signalR has finished the sync, stop the timer
1064
+ // If backend indicates completion (prefer queue-empty when available), stop the timer
1033
1065
  console.log("this.tenantNodes", this.tenantNodes)
1034
- let isCompletedOrNothingToSync = this.tenantNodes.map((tn: TenantNode) => tn.targets.map((ta) => ta.status === "complete" || tn.nothingtosync).reduce((prev, next) => prev && next)).reduce((prev, next) => prev && next);
1035
- if (isCompletedOrNothingToSync) {
1066
+ let isCompletedOrNothingToSync = this.tenantNodes.every((tn: TenantNode) =>
1067
+ tn.nothingtosync || tn.targets.every((ta) => ta.status === "complete" || ta.status === "failed")
1068
+ );
1069
+
1070
+ const queuesEmpty = this.hasQueueInfo &&
1071
+ this.lastQueues.main === 0 &&
1072
+ this.lastQueues.writer === 0 &&
1073
+ this.lastQueues.deferred === 0;
1074
+
1075
+ // If we have queue info, trust it for completion. Otherwise fall back to node terminal states.
1076
+ const isCompleted = queuesEmpty || (!this.hasQueueInfo && isCompletedOrNothingToSync);
1077
+
1078
+ if (isCompleted) {
1036
1079
  clearInterval(this.pb_timer!);
1037
1080
  this.pb_timer = null;
1038
1081
  this.pb_progress = 100;
1039
1082
  setSyncProgress(this.pb_progress);
1040
- setIdleText(`Complete. [max idle: ${this.pb_idleMax}]`);
1083
+ setIdleText(`Complete.`);
1084
+ this.stopPolling();
1085
+ this.clearStoredBatchIds();
1041
1086
  }
1042
1087
  else {
1043
- // if we've gone 60 seconds without a signalR message, finish the sync
1044
1088
  this.pb_total = this.pb_total + 1;
1045
1089
  this.pb_idle = this.pb_idle + 1;
1046
- this.pb_idleMax = Math.max(this.pb_idle, this.pb_idleMax);
1047
- setIdleText(`${this.pb_total} seconds elapsed. Last update ${this.pb_idle} seconds ago. [max idle: ${this.pb_idleMax}/60]`);
1048
- if (this.pb_idle >= 60) {
1049
- if (this.milestoneArray.milestones[0].Write == null) {
1050
- //this.milestoneArray.write(setMilestones); -- allow sync to cntinue
1051
- setConfigSyncResult(`sync continuing, but no update for ${this.pb_idle} seconds`);
1052
- }
1053
- }
1054
- // if we get to 100, the progress bar stops but SignalR or countdown timer completes the sync
1055
- if (this.pb_progress < 100) {
1056
- this.pb_progress = Math.min(100, this.pb_progress + this.pb_increment);
1057
- setSyncProgress(this.pb_progress);
1058
- }
1090
+
1091
+ setIdleText(`${this.pb_total} seconds elapsed. Last update ${this.pb_idle} seconds ago.`);
1059
1092
  }
1060
1093
  }, 1000);
1061
1094
  this.milestoneArray.start(setMilestones);
1062
1095
  }
1096
+
1097
+ private stopPolling(): void {
1098
+ if (this.pollTimer != null) {
1099
+ clearInterval(this.pollTimer);
1100
+ this.pollTimer = null;
1101
+ }
1102
+ this.pollInstance = null;
1103
+ this.pollAuthorizedUser = null;
1104
+ this.pollBatchIdArray = [];
1105
+ this.pollLastUpdatedByBatchId = {};
1106
+ }
1107
+
1108
+ private async pollStatsOnce(): Promise<void> {
1109
+ // Polling-only mode (single source of truth: /api/stats).
1110
+ if (this.pb_timer == null) return;
1111
+ if (this.pollInstance == null || this.pollAuthorizedUser == null) return;
1112
+ if (!this.statsHydrationHandler) return;
1113
+ if (!this.pollBatchIdArray || this.pollBatchIdArray.length === 0) return;
1114
+
1115
+ for (const batchPair of this.pollBatchIdArray) {
1116
+ const batchId: string | undefined = batchPair?.BatchId;
1117
+ if (!batchId) continue;
1118
+
1119
+ const statsResult: APIResult = await readerStats(this.pollInstance, this.pollAuthorizedUser, batchId);
1120
+ if (!statsResult.result || !statsResult.array || !statsResult.array[0]) continue;
1121
+
1122
+ const payload: any = statsResult.array[0];
1123
+ const lastUpdated: string | undefined = payload.lastUpdated;
1124
+ const prevLastUpdated = this.pollLastUpdatedByBatchId[batchId];
1125
+ this.pollLastUpdatedByBatchId[batchId] = lastUpdated ?? "";
1126
+
1127
+ // Only feed the handler if backend state changed.
1128
+ if (!lastUpdated || lastUpdated === prevLastUpdated) continue;
1129
+
1130
+ this.statsHydrationHandler(batchId, payload.stats);
1131
+ }
1132
+ }
1063
1133
  uninitializeProgressBar(setSyncProgress: (progress: number) => void, setConfigSyncResult: (result: string) => void, setIdleText: (idleText: string) => void, setMilestones: (milestones: Milestone[]) => void): void {
1064
1134
  this.pb_startTS = 0;
1065
1135
  this.pb_progress = 0;
1066
1136
  setSyncProgress(this.pb_progress);
1067
1137
  setConfigSyncResult("sync failed to execute");
1068
- this.pb_increment = 0;
1069
1138
  clearInterval(this.pb_timer!);
1070
1139
  this.pb_timer = null;
1071
1140
  this.pb_idle = 0;
1072
- this.pb_idleMax = 0;
1073
- setIdleText(`No updates seen for ${this.pb_idle} seconds. [max idle: ${this.pb_idleMax}]`);
1141
+ setIdleText(`No updates seen for ${this.pb_idle} seconds.`);
1074
1142
  this.milestoneArray.unstart(setMilestones);
1075
1143
  }
1144
+ /**
1145
+ * Calculates the sync progress percentage (0-99%) based on completed read and write operations.
1146
+ *
1147
+ * The progress formula is: (completedReads + completedWrites) / (total * 2) * 100
1148
+ *
1149
+ * Progress is divided by (total * 2) because the sync process has two phases:
1150
+ * 1. Reading phase: read users from source + excluded users
1151
+ * 2. Writing phase: write users to target + excluded users + deferred users
1152
+ *
1153
+ * Each phase can contribute up to 50% of the total progress. The result is capped at 99%
1154
+ * to prevent premature completion (100% is only shown when sync is truly complete).
1155
+ *
1156
+ * @param total - Total number of users to sync
1157
+ * @param read - Number of users read from source
1158
+ * @param written - Number of users written to target
1159
+ * @param excluded - Number of users excluded from sync
1160
+ * @param deferred - Number of users deferred (postponed) during writing
1161
+ * @returns Progress percentage from 0 to 99
1162
+ */
1163
+ calculateProgress(total: number, read: number, written: number, excluded: number, deferred: number): number {
1164
+ if (total <= 0) {
1165
+ return 0;
1166
+ }
1167
+ const completedReads = Math.min(total, read + excluded);
1168
+ const completedWrites = Math.min(total, written + excluded + deferred);
1169
+ const completedUnits = completedReads + completedWrites;
1170
+ return Math.min(99, Math.round((completedUnits / (total * 2)) * 100));
1171
+ }
1172
+ updateProgressFromTotals(total: number, read: number, written: number, excluded: number, deferred: number, setSyncProgress: (progress: number) => void): void {
1173
+ const progress = this.calculateProgress(total, read, written, excluded, deferred);
1174
+ this.pb_progress = Math.max(this.pb_progress, progress);
1175
+ setSyncProgress(this.pb_progress);
1176
+
1177
+ // Persist progress so a page refresh can restore the same (or higher) percent.
1178
+ // Note: This is intentionally monotonic because pb_progress is monotonic.
1179
+ if (storageAvailable()) {
1180
+ localStorage.setItem("BatchIdArrayProgress", String(this.pb_progress));
1181
+ }
1182
+ }
1076
1183
  initializeSignalR(
1077
1184
  config: SyncConfig | null | undefined,
1078
1185
  syncPortalGlobalState: InitInfo | null,
@@ -1086,25 +1193,32 @@ export class BatchArray {
1086
1193
  setWritersCurrent: (writersCurrent: number) => void,
1087
1194
  setMilestones: (milestones: Milestone[]) => void,
1088
1195
  setConfigSyncResult: (result: string) => void,
1196
+ setSyncProgress: (progress: number) => void,
1089
1197
  bClearLocalStorage: boolean,
1090
- message: string
1198
+ message: string,
1199
+ instance?: IPublicClientApplication,
1200
+ authorizedUser?: User
1091
1201
  ): void {
1092
1202
  bClearLocalStorage = bClearLocalStorage;
1203
+ message = message;
1204
+
1205
+ // Starting a new SignalR session for a (potentially new) run: reset queue-based completion signals.
1206
+ this.hasQueueInfo = false;
1207
+ this.lastQueues = { main: null, writer: null, deferred: null };
1208
+
1093
1209
  // we have just completed a successful POST to startSync
1094
1210
  this.milestoneArray.post(setMilestones);
1095
1211
  setConfigSyncResult("started sync, waiting for updates...");
1096
1212
  // re-initialize batch array with Configuration updated by the succcessful POST to startSync
1097
1213
  this.init(config, syncPortalGlobalState, false);
1098
- // define newMessage handler that can access *this*
1099
- let handler = (connection: signalR.HubConnection) => (message: string) => {
1100
- console.log(message);
1101
- let item = JSON.parse(message);
1214
+ // define a stats hydration handler (used by polling)
1215
+ let handler = (batchId: string, statsarray: any) => {
1102
1216
  // reset the countdown timer every time we get a message
1103
1217
  this.pb_idle = 0;
1104
- // find the associated tenant for this SignalR message
1105
- let matchingPair: any | undefined = batchIdArray.find((o: any) => o.BatchId == item.TargetID);
1218
+ // find the associated tenant for this batchId
1219
+ let matchingPair: any | undefined = batchIdArray.find((o: any) => o.BatchId == batchId);
1106
1220
  if (matchingPair == null) {
1107
- console.log(`Batch ${item.TargetID} not found in batchIdArray.`);
1221
+ console.log(`Batch ${batchId} not found in batchIdArray.`);
1108
1222
  debugger;
1109
1223
  return;
1110
1224
  }
@@ -1115,13 +1229,16 @@ export class BatchArray {
1115
1229
  return;
1116
1230
  }
1117
1231
  tenantNode.batchId = matchingPair.BatchId;
1118
- // process stats for this SignalR message (one batch per tenant node)
1119
- let statsarray = item.Stats; // get the array of statistics
1232
+ // process stats for this batch (one batch per tenant node)
1120
1233
  let statskeys = Object.keys(statsarray); // get the keys of the array
1121
1234
  let statsvalues = Object.values(statsarray); // get the values of the array
1122
1235
  // does this tenantnode/batch have nothing to sync?
1123
1236
  let bTotalCountZero: boolean = false;
1124
1237
  let bCurrentCountZero: boolean = false;
1238
+ // queue stats (better completion signal than only written/total)
1239
+ let totalInMainQueue: number | null = null;
1240
+ let totalInWriterQueue: number | null = null;
1241
+ let totalInDeferredQueue: number | null = null;
1125
1242
  for (let j = 0; j < statskeys.length; j++) {
1126
1243
  let bTotalCount = statskeys[j].endsWith("TotalCount");
1127
1244
  let bCurrentCount = statskeys[j].endsWith("CurrentCount");
@@ -1174,9 +1291,21 @@ export class BatchArray {
1174
1291
  }
1175
1292
  }
1176
1293
  tenantNode.nothingtosync = bTotalCountZero && bCurrentCountZero;
1294
+
1295
+ // queue stats are global for the batch
1296
+ if (statskeys[j] === "TotalInMainQueue") {
1297
+ totalInMainQueue = Number(statsvalues[j]);
1298
+ }
1299
+ if (statskeys[j] === "TotalInWriterQueue") {
1300
+ totalInWriterQueue = Number(statsvalues[j]);
1301
+ }
1302
+ if (statskeys[j] === "TotalInDeferredQueue") {
1303
+ totalInDeferredQueue = Number(statsvalues[j]);
1304
+ }
1305
+
1177
1306
  if (statskeys[j].startsWith("Writer")) {
1178
1307
  // parse tid from Writer key
1179
- let tidRegexp = /Reader\/TID:(.+)\/TotalCount/;
1308
+ let tidRegexp = /Writer\/TID:(.+)\/TotalCount/;
1180
1309
  if (bCurrentCount) tidRegexp = /Writer\/TID:(.+)\/CurrentCount/;
1181
1310
  if (bExcludedCount) tidRegexp = /Writer\/TID:(.+)\/ExtCount/;
1182
1311
  if (bDeferredCount) tidRegexp = /Writer\/TID:(.+)\/DeferredCount/;
@@ -1198,7 +1327,7 @@ export class BatchArray {
1198
1327
  writerNode.total = Math.max(Number(tenantNode.total), writerNode.total);
1199
1328
  writerNode.batchId = matchingPair.BatchId;
1200
1329
  if (bTotalCount) {
1201
- writerNode.total = Math.max(Number(bTotalCount), writerNode.total);
1330
+ writerNode.total = Math.max(Number(statsvalues[j]), writerNode.total);
1202
1331
  console.log(`----- ${writerNode.name} TID: ${writerNode.tid} batchId: ${writerNode.batchId}`);
1203
1332
  console.log(`----- ${writerNode.name} Total To Write: ${writerNode.total}`);
1204
1333
  }
@@ -1232,6 +1361,7 @@ export class BatchArray {
1232
1361
  let writerTotal: number = 0;
1233
1362
  let writerCurrent: number = 0;
1234
1363
  let writerExcluded: number = 0;
1364
+ let writerDeferred: number = 0;
1235
1365
  this.tenantNodes.map((sourceTenantNode: TenantNode) => {
1236
1366
  sourceTenantNode.targets.map((writerNode: TenantNode) => {
1237
1367
  bWritingComplete &&= (writerNode.status == "complete" || writerNode.status == "failed");
@@ -1239,13 +1369,40 @@ export class BatchArray {
1239
1369
  writerTotal += Math.max(writerNode.total, sourceTenantNode.total);
1240
1370
  writerCurrent += writerNode.written;
1241
1371
  writerExcluded += writerNode.excluded;
1372
+ writerDeferred += writerNode.deferred;
1242
1373
  });
1243
1374
  bNothingToSync &&= sourceTenantNode.nothingtosync;
1244
- bReadingComplete &&= (sourceTenantNode.status == "complete" || sourceTenantNode.status == "failed");
1375
+ // Reading completion should be derived from read/excluded totals, not from source status.
1376
+ bReadingComplete &&= (sourceTenantNode.total > 0 && (sourceTenantNode.read + sourceTenantNode.excluded) >= sourceTenantNode.total);
1245
1377
  readerTotal += sourceTenantNode.total;
1246
1378
  readerCurrent += sourceTenantNode.read;
1247
1379
  readerExcluded += sourceTenantNode.excluded;
1248
1380
  });
1381
+
1382
+ // If queue stats are available, they are a stronger indicator of write completion.
1383
+ // When all queues are empty, the backend has finished processing this batch and may stop emitting updates.
1384
+ if (totalInMainQueue != null) this.lastQueues.main = totalInMainQueue;
1385
+ if (totalInWriterQueue != null) this.lastQueues.writer = totalInWriterQueue;
1386
+ if (totalInDeferredQueue != null) this.lastQueues.deferred = totalInDeferredQueue;
1387
+ this.hasQueueInfo = this.lastQueues.main != null && this.lastQueues.writer != null && this.lastQueues.deferred != null;
1388
+
1389
+ const queuesEmpty = this.hasQueueInfo &&
1390
+ this.lastQueues.main === 0 &&
1391
+ this.lastQueues.writer === 0 &&
1392
+ this.lastQueues.deferred === 0;
1393
+
1394
+ if (queuesEmpty) {
1395
+ bWritingComplete = true;
1396
+
1397
+ // Ensure the UI can reach a terminal state even if writer CurrentCount never reaches TotalCount.
1398
+ // We trust the backend queues more than client-side totals in this scenario.
1399
+ this.tenantNodes.forEach((src) => {
1400
+ src.targets.forEach((t) => {
1401
+ if (t.status !== "failed") t.status = "complete";
1402
+ });
1403
+ if (src.status !== "failed") src.status = "complete";
1404
+ });
1405
+ }
1249
1406
  // set linear gauge max and current values
1250
1407
  setReadersTotal(readerTotal);
1251
1408
  setReadersCurrent(readerCurrent);
@@ -1253,11 +1410,34 @@ export class BatchArray {
1253
1410
  setWritersTotal(Math.max(writerTotal, readerTotal));
1254
1411
  setWritersCurrent(writerCurrent);
1255
1412
  setWritersExcluded(writerExcluded);
1413
+
1414
+ // update progress bar based on backend totals (monotonic)
1415
+ // This avoids progress decreasing after refresh when local timer state restarts.
1416
+ // Excluded users count as completed for both read and write.
1417
+ // Deferred users count as completed for write.
1418
+ this.updateProgressFromTotals(
1419
+ Math.max(readerTotal, writerTotal),
1420
+ readerCurrent,
1421
+ writerCurrent,
1422
+ Math.max(readerExcluded, writerExcluded),
1423
+ // Deferred users count as completed for write.
1424
+ writerDeferred,
1425
+ setSyncProgress
1426
+ );
1256
1427
  // check to see if there was nothing to sync
1257
1428
  if (bNothingToSync) {
1258
1429
  this.milestoneArray.write(setMilestones);
1259
- connection.stop();
1430
+ this.stopPolling();
1260
1431
  setConfigSyncResult("nothing to sync");
1432
+ // force completion visuals
1433
+ if (this.pb_timer) {
1434
+ clearInterval(this.pb_timer);
1435
+ this.pb_timer = null;
1436
+ }
1437
+ this.pb_progress = 100;
1438
+ setSyncProgress(this.pb_progress);
1439
+ this.setIdleText?.(`Complete (nothing to sync).`);
1440
+ this.clearStoredBatchIds();
1261
1441
  console.log(`Setting config sync result: "nothing to sync"`);
1262
1442
  }
1263
1443
  else {
@@ -1268,19 +1448,22 @@ export class BatchArray {
1268
1448
  console.log(`Setting config sync result: "reading complete"`);
1269
1449
  // trigger refresh delta tokens
1270
1450
  setRefreshDeltaTrigger(config!.workspaceId);
1271
- // change to % per second to complete in 12x as long as it took to get here
1272
- let readTS = Date.now();
1273
- let secsElapsed = (readTS - this.pb_startTS) / 1000;
1274
- let expectedPercentDone = 8.5;
1275
- let expectedPercentPerSecond = secsElapsed / expectedPercentDone;
1276
- this.pb_increment = expectedPercentPerSecond;
1277
- console.log(`Setting increment: ${this.pb_increment}% per second`);
1278
1451
  }
1279
1452
  // with that out of the way, is writing complete?
1280
- if (bWritingComplete) {
1453
+ // Only allow terminal completion when backend queues are empty (if we have queue info).
1454
+ if (bWritingComplete && (queuesEmpty || !this.hasQueueInfo)) {
1281
1455
  this.milestoneArray.write(setMilestones);
1282
- connection.stop();
1456
+ this.stopPolling();
1283
1457
  setConfigSyncResult("sync complete");
1458
+ // force completion visuals
1459
+ if (this.pb_timer) {
1460
+ clearInterval(this.pb_timer);
1461
+ this.pb_timer = null;
1462
+ }
1463
+ this.pb_progress = 100;
1464
+ setSyncProgress(this.pb_progress);
1465
+ this.setIdleText?.(`Complete.`);
1466
+ this.clearStoredBatchIds();
1284
1467
  console.log(`Setting config sync result: "complete"`);
1285
1468
  }
1286
1469
  // if not, has writing even started?
@@ -1296,38 +1479,26 @@ export class BatchArray {
1296
1479
  }
1297
1480
  }
1298
1481
 
1299
- // start SignalR connection based on each batchId
1300
- batchIdArray.map((batchPair: any) => {
1301
- const endpoint: string = mindlineConfig.signalREndpoint();
1302
- let endpointUrl: URL = new URL(endpoint);
1303
- endpointUrl.searchParams.append("statsId", batchPair.BatchId);
1304
- console.log(`Creating SignalR Hub for TID: ${batchPair.SourceId} ${endpointUrl.href}`);
1305
- const connection: signalR.HubConnection = new signalR.HubConnectionBuilder()
1306
- .withUrl(endpointUrl.href)
1307
- .withAutomaticReconnect()
1308
- .configureLogging(signalR.LogLevel.Information)
1309
- .build();
1310
- // when you get a message, process the message
1311
- if (!!message && JSON.parse(message).TargetID === batchPair.BatchId) {
1312
- handler(connection)(message)
1313
- }
1314
- connection.on("newMessage", handler(connection));
1315
- connection.onreconnecting(error => {
1316
- console.assert(connection.state === signalR.HubConnectionState.Reconnecting);
1317
- console.log(`Connection lost due to error "${error}". Reconnecting.`);
1318
- });
1319
- connection.onreconnected(connectionId => {
1320
- console.assert(connection.state === signalR.HubConnectionState.Connected);
1321
- console.log(`Connection reestablished. Connected with connectionId "${connectionId}".`);
1322
- });
1323
- // restart when you get a close event
1324
- connection.onclose(async () => {
1325
- console.log(`Connection closing. Attempting restart.`);
1326
- await connection.start();
1327
- });
1328
- // start and display any caught exceptions in the console
1329
- connection.start().catch(console.error);
1330
- });
1482
+ // allow polling to feed the same handler
1483
+ this.statsHydrationHandler = handler;
1484
+
1485
+ // Start polling (only if caller provided credentials)
1486
+ if (instance && authorizedUser) {
1487
+ this.pollInstance = instance;
1488
+ this.pollAuthorizedUser = authorizedUser;
1489
+ this.pollBatchIdArray = batchIdArray as any[];
1490
+ this.stopPolling(); // clear any previous poller state
1491
+ this.pollInstance = instance;
1492
+ this.pollAuthorizedUser = authorizedUser;
1493
+ this.pollBatchIdArray = batchIdArray as any[];
1494
+
1495
+ // Hydrate immediately (no need to wait pollIntervalSeconds)
1496
+ void this.pollStatsOnce();
1497
+
1498
+ this.pollTimer = setInterval(() => {
1499
+ void this.pollStatsOnce();
1500
+ }, this.pollIntervalSeconds * 1000);
1501
+ }
1331
1502
  }
1332
1503
  // start a sync cycle
1333
1504
  async startSync(instance: IPublicClientApplication, authorizedUser: User | null | undefined, config: SyncConfig | null | undefined): Promise<APIResult> {
@@ -1375,8 +1546,20 @@ export class TenantNode {
1375
1546
  this.deferred = deferred;
1376
1547
  if (this.read === 0 && this.written === 0) this.status = "not started";
1377
1548
  if (this.read > 0) {
1378
- if (this.read + this.excluded < this.total) this.status = "in progress";
1379
- else if (this.read + this.excluded === this.total) this.status = "complete";
1549
+ if (this.read + this.excluded < this.total) {
1550
+ this.status = "in progress";
1551
+ }
1552
+ else if (this.read + this.excluded === this.total) {
1553
+ // For source nodes (nodes with targets), reading complete doesn't mean the whole branch is complete.
1554
+ // Avoid reporting "complete" while any target is still running.
1555
+ if (this.targets != null && this.targets.length > 0) {
1556
+ const allTargetsTerminal = this.targets.every(t => t.status === "complete" || t.status === "failed");
1557
+ this.status = allTargetsTerminal ? "complete" : "in progress";
1558
+ }
1559
+ else {
1560
+ this.status = "complete";
1561
+ }
1562
+ }
1380
1563
  }
1381
1564
  else if (this.written > 0) {
1382
1565
  if (this.written + this.deferred + this.excluded < this.total) this.status = "in progress";
@@ -2187,7 +2370,9 @@ export async function configsRefresh(instance: IPublicClientApplication, authori
2187
2370
  // console.log("Init Info-----------", currentConfig.id)
2188
2371
  // tag components with workspaceIDs
2189
2372
  ii.tagWithWorkspaces();
2190
- localStorage.setItem("BatchIdArray", "{}");
2373
+ // IMPORTANT:
2374
+ // Do not clear BatchIdArray here. It is used by the UI to restore an in-flight sync
2375
+ // after refresh/re-login. BatchIdArray should be cleared when the sync completes.
2191
2376
  return result;
2192
2377
  } else {
2193
2378
  // workspace not found
@@ -4176,4 +4361,4 @@ export async function readerStats(
4176
4361
  console.log(error.message);
4177
4362
  }
4178
4363
  return result;
4179
- }
4364
+ }