@mindline/sync 1.0.109 → 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.109",
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,16 +952,16 @@ 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 = .25;
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
  }
943
960
  clearStoredBatchIds(): void {
944
961
  if (storageAvailable()) {
945
962
  localStorage.setItem("BatchIdArray", "[]");
963
+ // Also clear any persisted UI progress for the in-flight batch restore.
964
+ localStorage.removeItem("BatchIdArrayProgress");
946
965
  }
947
966
  }
948
967
  // populate tenantNodes based on config tenants
@@ -1026,59 +1045,141 @@ export class BatchArray {
1026
1045
  });
1027
1046
  }
1028
1047
  }
1029
- 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
+
1030
1057
  this.pb_startTS = Date.now();
1031
1058
  this.pb_progress = 0;
1032
- this.pb_increment = .25;
1033
1059
  this.pb_idle = 0;
1034
- this.pb_idleMax = 0;
1035
1060
  this.pb_total = 0;
1061
+ setSyncProgress(this.pb_progress);
1062
+ setIdleText("Starting sync...");
1036
1063
  this.pb_timer = setInterval(() => {
1037
- // if signalR has finished the sync, stop the timer
1064
+ // If backend indicates completion (prefer queue-empty when available), stop the timer
1038
1065
  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) {
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) {
1041
1079
  clearInterval(this.pb_timer!);
1042
1080
  this.pb_timer = null;
1043
1081
  this.pb_progress = 100;
1044
1082
  setSyncProgress(this.pb_progress);
1045
- setIdleText(`Complete. [max idle: ${this.pb_idleMax}]`);
1083
+ setIdleText(`Complete.`);
1084
+ this.stopPolling();
1046
1085
  this.clearStoredBatchIds();
1047
1086
  }
1048
1087
  else {
1049
- // if we've gone 60 seconds without a signalR message, finish the sync
1050
1088
  this.pb_total = this.pb_total + 1;
1051
1089
  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
- }
1090
+
1091
+ setIdleText(`${this.pb_total} seconds elapsed. Last update ${this.pb_idle} seconds ago.`);
1065
1092
  }
1066
1093
  }, 1000);
1067
1094
  this.milestoneArray.start(setMilestones);
1068
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
+ }
1069
1133
  uninitializeProgressBar(setSyncProgress: (progress: number) => void, setConfigSyncResult: (result: string) => void, setIdleText: (idleText: string) => void, setMilestones: (milestones: Milestone[]) => void): void {
1070
1134
  this.pb_startTS = 0;
1071
1135
  this.pb_progress = 0;
1072
1136
  setSyncProgress(this.pb_progress);
1073
1137
  setConfigSyncResult("sync failed to execute");
1074
- this.pb_increment = 0;
1075
1138
  clearInterval(this.pb_timer!);
1076
1139
  this.pb_timer = null;
1077
1140
  this.pb_idle = 0;
1078
- this.pb_idleMax = 0;
1079
- setIdleText(`No updates seen for ${this.pb_idle} seconds. [max idle: ${this.pb_idleMax}]`);
1141
+ setIdleText(`No updates seen for ${this.pb_idle} seconds.`);
1080
1142
  this.milestoneArray.unstart(setMilestones);
1081
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
+ }
1082
1183
  initializeSignalR(
1083
1184
  config: SyncConfig | null | undefined,
1084
1185
  syncPortalGlobalState: InitInfo | null,
@@ -1092,25 +1193,32 @@ export class BatchArray {
1092
1193
  setWritersCurrent: (writersCurrent: number) => void,
1093
1194
  setMilestones: (milestones: Milestone[]) => void,
1094
1195
  setConfigSyncResult: (result: string) => void,
1196
+ setSyncProgress: (progress: number) => void,
1095
1197
  bClearLocalStorage: boolean,
1096
- message: string
1198
+ message: string,
1199
+ instance?: IPublicClientApplication,
1200
+ authorizedUser?: User
1097
1201
  ): void {
1098
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
+
1099
1209
  // we have just completed a successful POST to startSync
1100
1210
  this.milestoneArray.post(setMilestones);
1101
1211
  setConfigSyncResult("started sync, waiting for updates...");
1102
1212
  // re-initialize batch array with Configuration updated by the succcessful POST to startSync
1103
1213
  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);
1214
+ // define a stats hydration handler (used by polling)
1215
+ let handler = (batchId: string, statsarray: any) => {
1108
1216
  // reset the countdown timer every time we get a message
1109
1217
  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);
1218
+ // find the associated tenant for this batchId
1219
+ let matchingPair: any | undefined = batchIdArray.find((o: any) => o.BatchId == batchId);
1112
1220
  if (matchingPair == null) {
1113
- console.log(`Batch ${item.TargetID} not found in batchIdArray.`);
1221
+ console.log(`Batch ${batchId} not found in batchIdArray.`);
1114
1222
  debugger;
1115
1223
  return;
1116
1224
  }
@@ -1121,13 +1229,16 @@ export class BatchArray {
1121
1229
  return;
1122
1230
  }
1123
1231
  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
1232
+ // process stats for this batch (one batch per tenant node)
1126
1233
  let statskeys = Object.keys(statsarray); // get the keys of the array
1127
1234
  let statsvalues = Object.values(statsarray); // get the values of the array
1128
1235
  // does this tenantnode/batch have nothing to sync?
1129
1236
  let bTotalCountZero: boolean = false;
1130
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;
1131
1242
  for (let j = 0; j < statskeys.length; j++) {
1132
1243
  let bTotalCount = statskeys[j].endsWith("TotalCount");
1133
1244
  let bCurrentCount = statskeys[j].endsWith("CurrentCount");
@@ -1180,9 +1291,21 @@ export class BatchArray {
1180
1291
  }
1181
1292
  }
1182
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
+
1183
1306
  if (statskeys[j].startsWith("Writer")) {
1184
1307
  // parse tid from Writer key
1185
- let tidRegexp = /Reader\/TID:(.+)\/TotalCount/;
1308
+ let tidRegexp = /Writer\/TID:(.+)\/TotalCount/;
1186
1309
  if (bCurrentCount) tidRegexp = /Writer\/TID:(.+)\/CurrentCount/;
1187
1310
  if (bExcludedCount) tidRegexp = /Writer\/TID:(.+)\/ExtCount/;
1188
1311
  if (bDeferredCount) tidRegexp = /Writer\/TID:(.+)\/DeferredCount/;
@@ -1204,7 +1327,7 @@ export class BatchArray {
1204
1327
  writerNode.total = Math.max(Number(tenantNode.total), writerNode.total);
1205
1328
  writerNode.batchId = matchingPair.BatchId;
1206
1329
  if (bTotalCount) {
1207
- writerNode.total = Math.max(Number(bTotalCount), writerNode.total);
1330
+ writerNode.total = Math.max(Number(statsvalues[j]), writerNode.total);
1208
1331
  console.log(`----- ${writerNode.name} TID: ${writerNode.tid} batchId: ${writerNode.batchId}`);
1209
1332
  console.log(`----- ${writerNode.name} Total To Write: ${writerNode.total}`);
1210
1333
  }
@@ -1238,6 +1361,7 @@ export class BatchArray {
1238
1361
  let writerTotal: number = 0;
1239
1362
  let writerCurrent: number = 0;
1240
1363
  let writerExcluded: number = 0;
1364
+ let writerDeferred: number = 0;
1241
1365
  this.tenantNodes.map((sourceTenantNode: TenantNode) => {
1242
1366
  sourceTenantNode.targets.map((writerNode: TenantNode) => {
1243
1367
  bWritingComplete &&= (writerNode.status == "complete" || writerNode.status == "failed");
@@ -1245,13 +1369,40 @@ export class BatchArray {
1245
1369
  writerTotal += Math.max(writerNode.total, sourceTenantNode.total);
1246
1370
  writerCurrent += writerNode.written;
1247
1371
  writerExcluded += writerNode.excluded;
1372
+ writerDeferred += writerNode.deferred;
1248
1373
  });
1249
1374
  bNothingToSync &&= sourceTenantNode.nothingtosync;
1250
- 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);
1251
1377
  readerTotal += sourceTenantNode.total;
1252
1378
  readerCurrent += sourceTenantNode.read;
1253
1379
  readerExcluded += sourceTenantNode.excluded;
1254
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
+ }
1255
1406
  // set linear gauge max and current values
1256
1407
  setReadersTotal(readerTotal);
1257
1408
  setReadersCurrent(readerCurrent);
@@ -1259,11 +1410,33 @@ export class BatchArray {
1259
1410
  setWritersTotal(Math.max(writerTotal, readerTotal));
1260
1411
  setWritersCurrent(writerCurrent);
1261
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
+ );
1262
1427
  // check to see if there was nothing to sync
1263
1428
  if (bNothingToSync) {
1264
1429
  this.milestoneArray.write(setMilestones);
1265
- connection.stop();
1430
+ this.stopPolling();
1266
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).`);
1267
1440
  this.clearStoredBatchIds();
1268
1441
  console.log(`Setting config sync result: "nothing to sync"`);
1269
1442
  }
@@ -1275,19 +1448,21 @@ export class BatchArray {
1275
1448
  console.log(`Setting config sync result: "reading complete"`);
1276
1449
  // trigger refresh delta tokens
1277
1450
  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
1451
  }
1286
1452
  // with that out of the way, is writing complete?
1287
- if (bWritingComplete) {
1453
+ // Only allow terminal completion when backend queues are empty (if we have queue info).
1454
+ if (bWritingComplete && (queuesEmpty || !this.hasQueueInfo)) {
1288
1455
  this.milestoneArray.write(setMilestones);
1289
- connection.stop();
1456
+ this.stopPolling();
1290
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.`);
1291
1466
  this.clearStoredBatchIds();
1292
1467
  console.log(`Setting config sync result: "complete"`);
1293
1468
  }
@@ -1304,38 +1479,26 @@ export class BatchArray {
1304
1479
  }
1305
1480
  }
1306
1481
 
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
- });
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
+ }
1339
1502
  }
1340
1503
  // start a sync cycle
1341
1504
  async startSync(instance: IPublicClientApplication, authorizedUser: User | null | undefined, config: SyncConfig | null | undefined): Promise<APIResult> {
@@ -1383,8 +1546,20 @@ export class TenantNode {
1383
1546
  this.deferred = deferred;
1384
1547
  if (this.read === 0 && this.written === 0) this.status = "not started";
1385
1548
  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";
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
+ }
1388
1563
  }
1389
1564
  else if (this.written > 0) {
1390
1565
  if (this.written + this.deferred + this.excluded < this.total) this.status = "in progress";