@mindline/sync 1.0.109 → 1.0.111

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.109",
3
+ "version": "1.0.111",
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,24 @@ 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
+
933
+ // Store UI callbacks so we can update the UI from poll/completion events.
934
+ private setIdleText: ((idleText: string) => void) | null = null;
935
+
936
+ // Handler used by polling: we reuse the same stats-processing logic.
937
+ private statsHydrationHandler: ((batchId: string, stats: any) => void) | null = null;
926
938
  milestoneArray: MilestoneArray;
927
939
  constructor(
928
940
  config: SyncConfig | null,
@@ -933,16 +945,16 @@ export class BatchArray {
933
945
  this.init(config, syncPortalGlobalState, bClearLocalStorage);
934
946
  this.pb_startTS = 0;
935
947
  this.pb_progress = 0;
936
- this.pb_increment = .25;
937
948
  this.pb_timer = null;
938
949
  this.pb_idle = 0;
939
- this.pb_idleMax = 0;
940
950
  this.pb_total = 0;
941
951
  this.milestoneArray = new MilestoneArray(false);
942
952
  }
943
953
  clearStoredBatchIds(): void {
944
954
  if (storageAvailable()) {
945
955
  localStorage.setItem("BatchIdArray", "[]");
956
+ // Also clear any persisted UI progress for the in-flight batch restore.
957
+ localStorage.removeItem("BatchIdArrayProgress");
946
958
  }
947
959
  }
948
960
  // populate tenantNodes based on config tenants
@@ -1026,59 +1038,116 @@ export class BatchArray {
1026
1038
  });
1027
1039
  }
1028
1040
  }
1029
- initializeProgressBar(setSyncProgress: (progress: number) => void, setConfigSyncResult: (result: string) => void, setIdleText: (idleText: string) => void, setMilestones: (milestones: Milestone[]) => void): void {
1041
+ initializeProgressBar(setSyncProgress: (progress: number) => void, _setConfigSyncResult: (result: string) => void, setIdleText: (idleText: string) => void, setMilestones: (milestones: Milestone[]) => void): void {
1042
+ // Save callback so completion/polling can update the idle text too.
1043
+ this.setIdleText = setIdleText;
1044
+
1045
+
1030
1046
  this.pb_startTS = Date.now();
1031
1047
  this.pb_progress = 0;
1032
- this.pb_increment = .25;
1033
1048
  this.pb_idle = 0;
1034
- this.pb_idleMax = 0;
1035
1049
  this.pb_total = 0;
1050
+ setSyncProgress(this.pb_progress);
1051
+ setIdleText("Starting sync...");
1036
1052
  this.pb_timer = setInterval(() => {
1037
- // if signalR has finished the sync, stop the timer
1053
+ // Completion check is now handled by the SignalR handler via isSyncCompleted()
1054
+ // This timer only increments elapsed time; actual completion logic lives in the handler.
1038
1055
  console.log("this.tenantNodes", this.tenantNodes)
1039
- 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);
1040
- if (isCompletedOrNothingToSync) {
1041
- clearInterval(this.pb_timer!);
1042
- this.pb_timer = null;
1043
- this.pb_progress = 100;
1044
- setSyncProgress(this.pb_progress);
1045
- setIdleText(`Complete. [max idle: ${this.pb_idleMax}]`);
1046
- this.clearStoredBatchIds();
1047
- }
1048
- else {
1049
- // if we've gone 60 seconds without a signalR message, finish the sync
1050
- this.pb_total = this.pb_total + 1;
1051
- this.pb_idle = this.pb_idle + 1;
1052
- this.pb_idleMax = Math.max(this.pb_idle, this.pb_idleMax);
1053
- setIdleText(`${this.pb_total} seconds elapsed. Last update ${this.pb_idle} seconds ago. [max idle: ${this.pb_idleMax}/60]`);
1054
- if (this.pb_idle >= 60) {
1055
- if (this.milestoneArray.milestones[0].Write == null) {
1056
- //this.milestoneArray.write(setMilestones); -- allow sync to cntinue
1057
- setConfigSyncResult(`sync continuing, but no update for ${this.pb_idle} seconds`);
1058
- }
1059
- }
1060
- // if we get to 100, the progress bar stops but SignalR or countdown timer completes the sync
1061
- if (this.pb_progress < 100) {
1062
- this.pb_progress = Math.min(100, this.pb_progress + this.pb_increment);
1063
- setSyncProgress(this.pb_progress);
1064
- }
1065
- }
1056
+
1057
+ this.pb_total = this.pb_total + 1;
1058
+ this.pb_idle = this.pb_idle + 1;
1059
+
1060
+ setIdleText(`${this.pb_total} seconds elapsed. Last update ${this.pb_idle} seconds ago.`);
1066
1061
  }, 1000);
1067
1062
  this.milestoneArray.start(setMilestones);
1068
1063
  }
1064
+
1065
+ private stopPolling(): void {
1066
+ if (this.pollTimer != null) {
1067
+ clearInterval(this.pollTimer);
1068
+ this.pollTimer = null;
1069
+ }
1070
+ this.pollInstance = null;
1071
+ this.pollAuthorizedUser = null;
1072
+ this.pollBatchIdArray = [];
1073
+ this.pollLastUpdatedByBatchId = {};
1074
+ }
1075
+
1076
+ private async pollStatsOnce(): Promise<void> {
1077
+ // Polling-only mode (single source of truth: /api/stats).
1078
+ if (this.pb_timer == null) return;
1079
+ if (this.pollInstance == null || this.pollAuthorizedUser == null) return;
1080
+ if (!this.statsHydrationHandler) return;
1081
+ if (!this.pollBatchIdArray || this.pollBatchIdArray.length === 0) return;
1082
+
1083
+ for (const batchPair of this.pollBatchIdArray) {
1084
+ const batchId: string | undefined = batchPair?.BatchId;
1085
+ if (!batchId) continue;
1086
+
1087
+ const statsResult: APIResult = await readerStats(this.pollInstance, this.pollAuthorizedUser, batchId);
1088
+ if (!statsResult.result || !statsResult.array || !statsResult.array[0]) continue;
1089
+
1090
+ const payload: any = statsResult.array[0];
1091
+ const lastUpdated: string | undefined = payload.lastUpdated;
1092
+ const prevLastUpdated = this.pollLastUpdatedByBatchId[batchId];
1093
+ this.pollLastUpdatedByBatchId[batchId] = lastUpdated ?? "";
1094
+
1095
+ // Only feed the handler if backend state changed.
1096
+ if (!lastUpdated || lastUpdated === prevLastUpdated) continue;
1097
+
1098
+ this.statsHydrationHandler(batchId, payload.stats);
1099
+ }
1100
+ }
1069
1101
  uninitializeProgressBar(setSyncProgress: (progress: number) => void, setConfigSyncResult: (result: string) => void, setIdleText: (idleText: string) => void, setMilestones: (milestones: Milestone[]) => void): void {
1070
1102
  this.pb_startTS = 0;
1071
1103
  this.pb_progress = 0;
1072
1104
  setSyncProgress(this.pb_progress);
1073
1105
  setConfigSyncResult("sync failed to execute");
1074
- this.pb_increment = 0;
1075
1106
  clearInterval(this.pb_timer!);
1076
1107
  this.pb_timer = null;
1077
1108
  this.pb_idle = 0;
1078
- this.pb_idleMax = 0;
1079
- setIdleText(`No updates seen for ${this.pb_idle} seconds. [max idle: ${this.pb_idleMax}]`);
1109
+ setIdleText(`No updates seen for ${this.pb_idle} seconds.`);
1080
1110
  this.milestoneArray.unstart(setMilestones);
1081
1111
  }
1112
+ /**
1113
+ * Calculates the sync progress percentage (0-99%) based on completed read and write operations.
1114
+ *
1115
+ * The progress formula is: (completedReads + completedWrites) / (total * 2) * 100
1116
+ *
1117
+ * Progress is divided by (total * 2) because the sync process has two phases:
1118
+ * 1. Reading phase: read users from source + excluded users
1119
+ * 2. Writing phase: write users to target + excluded users + deferred users
1120
+ *
1121
+ * Each phase can contribute up to 50% of the total progress. The result is capped at 99%
1122
+ * to prevent premature completion (100% is only shown when sync is truly complete).
1123
+ *
1124
+ * @param total - Total number of users to sync
1125
+ * @param read - Number of users read from source
1126
+ * @param written - Number of users written to target
1127
+ * @param excluded - Number of users excluded from sync
1128
+ * @param deferred - Number of users deferred (postponed) during writing
1129
+ * @returns Progress percentage from 0 to 99
1130
+ */
1131
+ calculateProgress(total: number, read: number, written: number, excluded: number, deferred: number): number {
1132
+ if (total <= 0) {
1133
+ return 0;
1134
+ }
1135
+ const completedReads = Math.min(total, read + excluded);
1136
+ const completedWrites = Math.min(total, written + excluded + deferred);
1137
+ const completedUnits = completedReads + completedWrites;
1138
+ return Math.min(99, Math.round((completedUnits / (total * 2)) * 100));
1139
+ }
1140
+ updateProgressFromTotals(total: number, read: number, written: number, excluded: number, deferred: number, setSyncProgress: (progress: number) => void): void {
1141
+ const progress = this.calculateProgress(total, read, written, excluded, deferred);
1142
+ this.pb_progress = Math.max(this.pb_progress, progress);
1143
+ setSyncProgress(this.pb_progress);
1144
+
1145
+ // Persist progress so a page refresh can restore the same (or higher) percent.
1146
+ // Note: This is intentionally monotonic because pb_progress is monotonic.
1147
+ if (storageAvailable()) {
1148
+ localStorage.setItem("BatchIdArrayProgress", String(this.pb_progress));
1149
+ }
1150
+ }
1082
1151
  initializeSignalR(
1083
1152
  config: SyncConfig | null | undefined,
1084
1153
  syncPortalGlobalState: InitInfo | null,
@@ -1092,25 +1161,29 @@ export class BatchArray {
1092
1161
  setWritersCurrent: (writersCurrent: number) => void,
1093
1162
  setMilestones: (milestones: Milestone[]) => void,
1094
1163
  setConfigSyncResult: (result: string) => void,
1164
+ setSyncProgress: (progress: number) => void,
1095
1165
  bClearLocalStorage: boolean,
1096
- message: string
1166
+ message: string,
1167
+ instance?: IPublicClientApplication,
1168
+ authorizedUser?: User
1097
1169
  ): void {
1098
1170
  bClearLocalStorage = bClearLocalStorage;
1171
+ message = message;
1172
+
1173
+
1099
1174
  // we have just completed a successful POST to startSync
1100
1175
  this.milestoneArray.post(setMilestones);
1101
1176
  setConfigSyncResult("started sync, waiting for updates...");
1102
1177
  // re-initialize batch array with Configuration updated by the succcessful POST to startSync
1103
1178
  this.init(config, syncPortalGlobalState, false);
1104
- // define newMessage handler that can access *this*
1105
- let handler = (connection: signalR.HubConnection) => (message: string) => {
1106
- console.log(message);
1107
- let item = JSON.parse(message);
1179
+ // define a stats hydration handler (used by polling)
1180
+ let handler = (batchId: string, statsarray: any) => {
1108
1181
  // reset the countdown timer every time we get a message
1109
1182
  this.pb_idle = 0;
1110
- // find the associated tenant for this SignalR message
1111
- let matchingPair: any | undefined = batchIdArray.find((o: any) => o.BatchId == item.TargetID);
1183
+ // find the associated tenant for this batchId
1184
+ let matchingPair: any | undefined = batchIdArray.find((o: any) => o.BatchId == batchId);
1112
1185
  if (matchingPair == null) {
1113
- console.log(`Batch ${item.TargetID} not found in batchIdArray.`);
1186
+ console.log(`Batch ${batchId} not found in batchIdArray.`);
1114
1187
  debugger;
1115
1188
  return;
1116
1189
  }
@@ -1121,8 +1194,7 @@ export class BatchArray {
1121
1194
  return;
1122
1195
  }
1123
1196
  tenantNode.batchId = matchingPair.BatchId;
1124
- // process stats for this SignalR message (one batch per tenant node)
1125
- let statsarray = item.Stats; // get the array of statistics
1197
+ // process stats for this batch (one batch per tenant node)
1126
1198
  let statskeys = Object.keys(statsarray); // get the keys of the array
1127
1199
  let statsvalues = Object.values(statsarray); // get the values of the array
1128
1200
  // does this tenantnode/batch have nothing to sync?
@@ -1180,9 +1252,11 @@ export class BatchArray {
1180
1252
  }
1181
1253
  }
1182
1254
  tenantNode.nothingtosync = bTotalCountZero && bCurrentCountZero;
1255
+
1256
+
1183
1257
  if (statskeys[j].startsWith("Writer")) {
1184
1258
  // parse tid from Writer key
1185
- let tidRegexp = /Reader\/TID:(.+)\/TotalCount/;
1259
+ let tidRegexp = /Writer\/TID:(.+)\/TotalCount/;
1186
1260
  if (bCurrentCount) tidRegexp = /Writer\/TID:(.+)\/CurrentCount/;
1187
1261
  if (bExcludedCount) tidRegexp = /Writer\/TID:(.+)\/ExtCount/;
1188
1262
  if (bDeferredCount) tidRegexp = /Writer\/TID:(.+)\/DeferredCount/;
@@ -1204,7 +1278,7 @@ export class BatchArray {
1204
1278
  writerNode.total = Math.max(Number(tenantNode.total), writerNode.total);
1205
1279
  writerNode.batchId = matchingPair.BatchId;
1206
1280
  if (bTotalCount) {
1207
- writerNode.total = Math.max(Number(bTotalCount), writerNode.total);
1281
+ writerNode.total = Math.max(Number(statsvalues[j]), writerNode.total);
1208
1282
  console.log(`----- ${writerNode.name} TID: ${writerNode.tid} batchId: ${writerNode.batchId}`);
1209
1283
  console.log(`----- ${writerNode.name} Total To Write: ${writerNode.total}`);
1210
1284
  }
@@ -1238,6 +1312,7 @@ export class BatchArray {
1238
1312
  let writerTotal: number = 0;
1239
1313
  let writerCurrent: number = 0;
1240
1314
  let writerExcluded: number = 0;
1315
+ let writerDeferred: number = 0;
1241
1316
  this.tenantNodes.map((sourceTenantNode: TenantNode) => {
1242
1317
  sourceTenantNode.targets.map((writerNode: TenantNode) => {
1243
1318
  bWritingComplete &&= (writerNode.status == "complete" || writerNode.status == "failed");
@@ -1245,13 +1320,34 @@ export class BatchArray {
1245
1320
  writerTotal += Math.max(writerNode.total, sourceTenantNode.total);
1246
1321
  writerCurrent += writerNode.written;
1247
1322
  writerExcluded += writerNode.excluded;
1323
+ writerDeferred += writerNode.deferred;
1248
1324
  });
1249
1325
  bNothingToSync &&= sourceTenantNode.nothingtosync;
1250
- bReadingComplete &&= (sourceTenantNode.status == "complete" || sourceTenantNode.status == "failed");
1326
+ // Reading completion should be derived from read/excluded totals, not from source status.
1327
+ bReadingComplete &&= (sourceTenantNode.total > 0 && (sourceTenantNode.read + sourceTenantNode.excluded) >= sourceTenantNode.total);
1251
1328
  readerTotal += sourceTenantNode.total;
1252
1329
  readerCurrent += sourceTenantNode.read;
1253
1330
  readerExcluded += sourceTenantNode.excluded;
1254
1331
  });
1332
+
1333
+ // New completion condition: sync is complete when:
1334
+ // (Reader/TotalCount - Reader/ExtCount) === (Writer/CurrentCount + Writer/DeferredCount)
1335
+ // This means all users that should be written (total minus excluded) have been processed.
1336
+ const usersToSync = readerTotal - readerExcluded;
1337
+ const usersProcessed = writerCurrent + writerDeferred;
1338
+ const isSyncCompleted = usersToSync > 0 && usersToSync === usersProcessed;
1339
+
1340
+ if (isSyncCompleted) {
1341
+ bWritingComplete = true;
1342
+
1343
+ // Mark all nodes as complete since sync condition is satisfied.
1344
+ this.tenantNodes.forEach((src) => {
1345
+ src.targets.forEach((t) => {
1346
+ if (t.status !== "failed") t.status = "complete";
1347
+ });
1348
+ if (src.status !== "failed") src.status = "complete";
1349
+ });
1350
+ }
1255
1351
  // set linear gauge max and current values
1256
1352
  setReadersTotal(readerTotal);
1257
1353
  setReadersCurrent(readerCurrent);
@@ -1259,11 +1355,33 @@ export class BatchArray {
1259
1355
  setWritersTotal(Math.max(writerTotal, readerTotal));
1260
1356
  setWritersCurrent(writerCurrent);
1261
1357
  setWritersExcluded(writerExcluded);
1358
+
1359
+ // update progress bar based on backend totals (monotonic)
1360
+ // This avoids progress decreasing after refresh when local timer state restarts.
1361
+ // Excluded users count as completed for both read and write.
1362
+ // Deferred users count as completed for write.
1363
+ this.updateProgressFromTotals(
1364
+ Math.max(readerTotal, writerTotal),
1365
+ readerCurrent,
1366
+ writerCurrent,
1367
+ Math.max(readerExcluded, writerExcluded),
1368
+ // Deferred users count as completed for write.
1369
+ writerDeferred,
1370
+ setSyncProgress
1371
+ );
1262
1372
  // check to see if there was nothing to sync
1263
1373
  if (bNothingToSync) {
1264
1374
  this.milestoneArray.write(setMilestones);
1265
- connection.stop();
1375
+ this.stopPolling();
1266
1376
  setConfigSyncResult("nothing to sync");
1377
+ // force completion visuals
1378
+ if (this.pb_timer) {
1379
+ clearInterval(this.pb_timer);
1380
+ this.pb_timer = null;
1381
+ }
1382
+ this.pb_progress = 100;
1383
+ setSyncProgress(this.pb_progress);
1384
+ this.setIdleText?.(`Complete (nothing to sync).`);
1267
1385
  this.clearStoredBatchIds();
1268
1386
  console.log(`Setting config sync result: "nothing to sync"`);
1269
1387
  }
@@ -1275,19 +1393,21 @@ export class BatchArray {
1275
1393
  console.log(`Setting config sync result: "reading complete"`);
1276
1394
  // trigger refresh delta tokens
1277
1395
  setRefreshDeltaTrigger(config!.workspaceId);
1278
- // change to % per second to complete in 12x as long as it took to get here
1279
- let readTS = Date.now();
1280
- let secsElapsed = (readTS - this.pb_startTS) / 1000;
1281
- let expectedPercentDone = 8.5;
1282
- let expectedPercentPerSecond = secsElapsed / expectedPercentDone;
1283
- this.pb_increment = expectedPercentPerSecond;
1284
- console.log(`Setting increment: ${this.pb_increment}% per second`);
1285
1396
  }
1286
1397
  // with that out of the way, is writing complete?
1287
- if (bWritingComplete) {
1398
+ // Completion is determined by the new formula: usersToSync === usersProcessed
1399
+ if (bWritingComplete && isSyncCompleted) {
1288
1400
  this.milestoneArray.write(setMilestones);
1289
- connection.stop();
1401
+ this.stopPolling();
1290
1402
  setConfigSyncResult("sync complete");
1403
+ // force completion visuals
1404
+ if (this.pb_timer) {
1405
+ clearInterval(this.pb_timer);
1406
+ this.pb_timer = null;
1407
+ }
1408
+ this.pb_progress = 100;
1409
+ setSyncProgress(this.pb_progress);
1410
+ this.setIdleText?.(`Complete.`);
1291
1411
  this.clearStoredBatchIds();
1292
1412
  console.log(`Setting config sync result: "complete"`);
1293
1413
  }
@@ -1304,38 +1424,26 @@ export class BatchArray {
1304
1424
  }
1305
1425
  }
1306
1426
 
1307
- // start SignalR connection based on each batchId
1308
- batchIdArray.map((batchPair: any) => {
1309
- const endpoint: string = mindlineConfig.signalREndpoint();
1310
- let endpointUrl: URL = new URL(endpoint);
1311
- endpointUrl.searchParams.append("statsId", batchPair.BatchId);
1312
- console.log(`Creating SignalR Hub for TID: ${batchPair.SourceId} ${endpointUrl.href}`);
1313
- const connection: signalR.HubConnection = new signalR.HubConnectionBuilder()
1314
- .withUrl(endpointUrl.href)
1315
- .withAutomaticReconnect()
1316
- .configureLogging(signalR.LogLevel.Information)
1317
- .build();
1318
- // when you get a message, process the message
1319
- if (!!message && JSON.parse(message).TargetID === batchPair.BatchId) {
1320
- handler(connection)(message)
1321
- }
1322
- connection.on("newMessage", handler(connection));
1323
- connection.onreconnecting(error => {
1324
- console.assert(connection.state === signalR.HubConnectionState.Reconnecting);
1325
- console.log(`Connection lost due to error "${error}". Reconnecting.`);
1326
- });
1327
- connection.onreconnected(connectionId => {
1328
- console.assert(connection.state === signalR.HubConnectionState.Connected);
1329
- console.log(`Connection reestablished. Connected with connectionId "${connectionId}".`);
1330
- });
1331
- // restart when you get a close event
1332
- connection.onclose(async () => {
1333
- console.log(`Connection closing. Attempting restart.`);
1334
- await connection.start();
1335
- });
1336
- // start and display any caught exceptions in the console
1337
- connection.start().catch(console.error);
1338
- });
1427
+ // allow polling to feed the same handler
1428
+ this.statsHydrationHandler = handler;
1429
+
1430
+ // Start polling (only if caller provided credentials)
1431
+ if (instance && authorizedUser) {
1432
+ this.pollInstance = instance;
1433
+ this.pollAuthorizedUser = authorizedUser;
1434
+ this.pollBatchIdArray = batchIdArray as any[];
1435
+ this.stopPolling(); // clear any previous poller state
1436
+ this.pollInstance = instance;
1437
+ this.pollAuthorizedUser = authorizedUser;
1438
+ this.pollBatchIdArray = batchIdArray as any[];
1439
+
1440
+ // Hydrate immediately (no need to wait pollIntervalSeconds)
1441
+ void this.pollStatsOnce();
1442
+
1443
+ this.pollTimer = setInterval(() => {
1444
+ void this.pollStatsOnce();
1445
+ }, this.pollIntervalSeconds * 1000);
1446
+ }
1339
1447
  }
1340
1448
  // start a sync cycle
1341
1449
  async startSync(instance: IPublicClientApplication, authorizedUser: User | null | undefined, config: SyncConfig | null | undefined): Promise<APIResult> {
@@ -1383,8 +1491,20 @@ export class TenantNode {
1383
1491
  this.deferred = deferred;
1384
1492
  if (this.read === 0 && this.written === 0) this.status = "not started";
1385
1493
  if (this.read > 0) {
1386
- if (this.read + this.excluded < this.total) this.status = "in progress";
1387
- else if (this.read + this.excluded === this.total) this.status = "complete";
1494
+ if (this.read + this.excluded < this.total) {
1495
+ this.status = "in progress";
1496
+ }
1497
+ else if (this.read + this.excluded === this.total) {
1498
+ // For source nodes (nodes with targets), reading complete doesn't mean the whole branch is complete.
1499
+ // Avoid reporting "complete" while any target is still running.
1500
+ if (this.targets != null && this.targets.length > 0) {
1501
+ const allTargetsTerminal = this.targets.every(t => t.status === "complete" || t.status === "failed");
1502
+ this.status = allTargetsTerminal ? "complete" : "in progress";
1503
+ }
1504
+ else {
1505
+ this.status = "complete";
1506
+ }
1507
+ }
1388
1508
  }
1389
1509
  else if (this.written > 0) {
1390
1510
  if (this.written + this.deferred + this.excluded < this.total) this.status = "in progress";