• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

graphty-org / graphty-element / 20514590651

26 Dec 2025 02:37AM UTC coverage: 70.559% (-0.3%) from 70.836%
20514590651

push

github

apowers313
ci: fix npm ci

9591 of 13363 branches covered (71.77%)

Branch coverage included in aggregate %.

25136 of 35854 relevant lines covered (70.11%)

6233.71 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.98
/src/managers/OperationQueueManager.ts
1
import PQueue from "p-queue";
15✔
2
import toposort from "toposort";
15!
3

4
import {OBSOLESCENCE_RULES} from "../constants/obsolescence-rules";
15✔
5
import {GraphtyLogger, type Logger} from "../logging/GraphtyLogger.js";
15✔
6
import type {OperationMetadata, OperationProgress} from "../types/operations";
7
import type {EventManager} from "./EventManager";
8
import type {Manager} from "./interfaces";
9

10
// Constants for operation queue management
11
const PROGRESS_CANCELLATION_THRESHOLD = 90; // Progress threshold for respecting in-progress operations
15✔
12
const CLEANUP_DELAY_MS = 1000; // Delay before cleaning up operation progress tracking
15✔
13
const OPERATION_POLL_INTERVAL_MS = 10; // Polling interval for waitForCompletion
15✔
14

15
export interface ProgressContext {
16
    setProgress(percent: number): void;
17
    setMessage(message: string): void;
18
    setPhase(phase: string): void;
19
}
20

21
export interface OperationContext {
22
    signal: AbortSignal;
23
    progress: ProgressContext;
24
    id: string;
25
}
26

27
export interface Operation {
28
    id: string;
29
    category: OperationCategory;
30
    execute: (context: OperationContext) => Promise<void> | void;
31
    abortController?: AbortController;
32
    metadata?: OperationMetadata;
33
    resolve?: (value: void | PromiseLike<void>) => void;
34
    reject?: (reason?: unknown) => void;
35
}
36

37
export type OperationCategory =
38
    | "style-init" // Initialize style template
39
    | "style-apply" // Apply styles to existing elements
40
    | "data-add" // Add nodes/edges
41
    | "data-remove" // Remove nodes/edges
42
    | "data-update" // Update node/edge properties
43
    | "layout-set" // Set layout engine
44
    | "layout-update" // Update layout positions
45
    | "algorithm-run" // Run graph algorithms
46
    | "camera-update" // Update camera position/mode
47
    | "render-update"; // Update rendering settings
48

49
/**
50
 * Manages a queue of graph operations with dependency resolution and batching
51
 * Ensures operations execute in the correct order based on their dependencies
52
 */
53
export class OperationQueueManager implements Manager {
15✔
54
    private queue: PQueue;
55
    private pendingOperations = new Map<string, Operation>();
23✔
56
    private operationCounter = 0;
23✔
57
    private batchingEnabled = true;
23✔
58
    private currentBatch = new Set<string>();
23✔
59
    private logger: Logger = GraphtyLogger.getLogger(["graphty", "operation"]);
23✔
60

61
    // Progress tracking
62
    private operationProgress = new Map<string, OperationProgress>();
23✔
63

64
    // Active operation controllers for cancellation
65
    private activeControllers = new Map<string, AbortController>();
23✔
66

67
    // Track running vs queued operations
68
    private runningOperations = new Map<string, Operation>();
23✔
69
    private queuedOperations = new Map<string, Operation>();
23✔
70

71
    // Track completed operation categories for cross-batch dependency resolution
72
    private completedCategories = new Set<OperationCategory>();
23✔
73

74
    // Batch mode support
75
    private batchMode = false;
23✔
76
    private batchOperations = new Set<string>();
23✔
77
    private batchPromises = new Map<string, Promise<void>>();
23✔
78

79
    // Trigger system
80
    private triggers = new Map<OperationCategory, ((metadata?: OperationMetadata) => {
23✔
81
        category: OperationCategory;
82
        execute: (context: OperationContext) => Promise<void> | void;
83
        description?: string;
84
    } | null)[]>();
23✔
85

86
    // Callback for when operations are queued (for testing)
87
    onOperationQueued?: (category: OperationCategory, description?: string) => void;
88

89
    // Check if layout engine exists (will be set by Graph)
90
    hasLayoutEngine?: () => boolean;
91

92
    // Dependency graph based on actual manager dependencies
93
    private static readonly CATEGORY_DEPENDENCIES: [OperationCategory, OperationCategory][] = [
23✔
94
        // Style must be initialized before applying
95
        ["style-apply", "style-init"],
23✔
96

97
        // Data operations depend on style being ready
98
        ["data-add", "style-init"],
23✔
99
        ["data-update", "style-init"],
23✔
100

101
        // Layout-set does NOT depend on data - it can create an empty layout engine
102
        // Data will be added to the layout engine when data-add runs later
103
        // ["layout-set", "data-add"], // REMOVED for stateless design
104

105
        // Layout-update DOES depend on data existing
106
        ["layout-update", "data-add"],
23✔
107
        // Layout-update depends on layout being set first
108
        ["layout-update", "layout-set"],
23✔
109

110
        // Algorithms depend on data
111
        ["algorithm-run", "data-add"],
23✔
112

113
        // Style application depends on algorithms (for calculated styles)
114
        ["style-apply", "algorithm-run"],
23✔
115

116
        // Camera updates may depend on layout for zoom-to-fit
117
        ["camera-update", "layout-set"],
23✔
118

119
        // Render updates come last
120
        ["render-update", "style-apply"],
23✔
121
        ["render-update", "data-add"],
23✔
122
        ["render-update", "layout-update"],
23✔
123
    ];
23✔
124

125
    // Post-execution triggers: operations that automatically trigger after other operations
126
    private static readonly POST_EXECUTION_TRIGGERS: Partial<Record<OperationCategory, OperationCategory[]>> = {
23✔
127
        "data-add": ["layout-update"],
23✔
128
        "data-remove": ["layout-update"],
23✔
129
        "data-update": ["layout-update"],
23✔
130
        "algorithm-run": ["style-apply"],
23✔
131
    };
23✔
132

133
    /**
134
     * Creates a new operation queue manager
135
     * @param eventManager - Event manager for emitting operation events
136
     * @param options - Queue configuration options
137
     * @param options.concurrency - Maximum concurrent operations (default: 1)
138
     * @param options.autoStart - Whether to auto-start the queue (default: true)
139
     * @param options.intervalCap - Maximum operations per interval
140
     * @param options.interval - Time interval in milliseconds
141
     */
142
    constructor(
23✔
143
        private eventManager: EventManager,
1,065✔
144
        options: {
1,065✔
145
            concurrency?: number;
146
            autoStart?: boolean;
147
            intervalCap?: number;
148
            interval?: number;
149
        } = {},
1,065✔
150
    ) {
1,065✔
151
        const queueOptions: {
1,065✔
152
            concurrency?: number;
153
            autoStart?: boolean;
154
            intervalCap?: number;
155
            interval?: number;
156
        } = {
1,065✔
157
            concurrency: options.concurrency ?? 1, // Sequential by default
1,065!
158
            autoStart: options.autoStart ?? true,
1,065!
159
        };
1,065✔
160

161
        if (options.intervalCap !== undefined) {
1,065!
162
            queueOptions.intervalCap = options.intervalCap;
×
163
        }
×
164

165
        if (options.interval !== undefined) {
1,065!
166
            queueOptions.interval = options.interval;
×
167
        }
×
168

169
        this.queue = new PQueue(queueOptions);
1,065✔
170

171
        // Listen for queue events
172
        this.queue.on("active", () => {
1,065✔
173
            this.eventManager.emitGraphEvent("operation-queue-active", {
40,837✔
174
                size: this.queue.size,
40,837✔
175
                pending: this.queue.pending,
40,837✔
176
            });
40,837✔
177
        });
1,065✔
178

179
        this.queue.on("idle", () => {
1,065✔
180
            this.eventManager.emitGraphEvent("operation-queue-idle", {});
2,092✔
181
        });
1,065✔
182
    }
1,065✔
183

184
    /**
185
     * Initialize the operation queue manager
186
     */
187
    async init(): Promise<void> {
23✔
188
        // No initialization needed
189
    }
939✔
190

191
    /**
192
     * Dispose the operation queue and cancel all active operations
193
     */
194
    dispose(): void {
23✔
195
        // Cancel all active operations
196
        this.activeControllers.forEach((controller) => {
778✔
197
            controller.abort();
38,735✔
198
        });
778✔
199

200
        this.queue.clear();
778✔
201
        this.pendingOperations.clear();
778✔
202
        this.operationProgress.clear();
778✔
203
        this.currentBatch.clear();
778✔
204
        this.activeControllers.clear();
778✔
205
        this.runningOperations.clear();
778✔
206
        this.queuedOperations.clear();
778✔
207
    }
778✔
208

209
    /**
210
     * Queue an operation for execution
211
     * Returns the operation ID
212
     * @param category - Category of the operation
213
     * @param execute - Function to execute for this operation
214
     * @param options - Optional metadata for the operation
215
     * @returns The unique operation ID
216
     */
217
    queueOperation(
23✔
218
        category: OperationCategory,
43,138✔
219
        execute: (context: OperationContext) => Promise<void> | void,
43,138✔
220
        options?: Partial<OperationMetadata>,
43,138✔
221
    ): string {
43,138✔
222
        const id = `op-${this.operationCounter++}`;
43,138✔
223
        const controller = new AbortController();
43,138✔
224

225
        // Initialize progress tracking with all fields
226
        this.operationProgress.set(id, {
43,138✔
227
            percent: 0,
43,138✔
228
            message: undefined,
43,138✔
229
            phase: undefined,
43,138✔
230
            startTime: Date.now(),
43,138✔
231
            lastUpdate: Date.now(),
43,138✔
232
        });
43,138✔
233

234
        const operation: Operation = {
43,138✔
235
            id,
43,138✔
236
            category,
43,138✔
237
            execute: async(ctx) => {
43,138✔
238
                const result = execute(ctx);
15,753✔
239
                if (result instanceof Promise) {
15,753✔
240
                    await result;
2,826✔
241
                }
2,798✔
242
            },
15,753✔
243
            abortController: controller,
43,138✔
244
            metadata: {
43,138✔
245
                ... options,
43,138✔
246
                timestamp: Date.now(),
43,138✔
247
            },
43,138✔
248
        };
43,138✔
249

250
        this.pendingOperations.set(id, operation);
43,138✔
251
        this.activeControllers.set(id, controller);
43,138✔
252

253
        this.logger.debug("Operation queued", {
43,138✔
254
            id,
43,138✔
255
            category,
43,138✔
256
            description: options?.description,
43,138✔
257
        });
43,138✔
258

259
        // Handle batch mode differently
260
        if (this.batchMode) {
43,138!
261
            // In batch mode: queue but don't execute yet
262
            this.batchOperations.add(id);
177✔
263
        } else {
43,138✔
264
            // Normal mode: add to current batch for immediate execution
265
            this.currentBatch.add(id);
42,961✔
266

267
            // Apply obsolescence rules before scheduling
268
            this.applyObsolescenceRules(operation);
42,961✔
269

270
            // Schedule batch execution
271
            if (this.batchingEnabled) {
42,961✔
272
                // When batching is enabled, schedule batch execution on next microtask
273
                if (this.currentBatch.size === 1) {
42,961✔
274
                    queueMicrotask(() => {
42,917✔
275
                        this.executeBatch();
42,917✔
276
                    });
42,917✔
277
                }
42,917✔
278
            } else {
42,961!
279
                // When batching is disabled, execute immediately
280
                queueMicrotask(() => {
×
281
                    this.executeBatch();
×
282
                });
×
283
            }
×
284
        }
42,961✔
285

286
        return id;
43,138✔
287
    }
43,138✔
288

289
    /**
290
     * Apply obsolescence rules for a new operation
291
     * @param newOperation - The new operation to check for obsolescence rules
292
     */
293
    private applyObsolescenceRules(newOperation: Operation): void {
23✔
294
        const {metadata} = newOperation;
42,961✔
295
        const defaultRule = OBSOLESCENCE_RULES[newOperation.category];
42,961✔
296
        // Only apply obsolescence if explicitly requested via metadata or default rules
297
        if (!metadata?.obsoletes &&
42,961✔
298
            !metadata?.shouldObsolete &&
42,949✔
299
            !metadata?.respectProgress &&
42,944✔
300
            !metadata?.skipRunning &&
42,944✔
301
            !defaultRule?.obsoletes) {
42,961✔
302
            return;
154✔
303
        }
154✔
304

305
        // Get obsolescence rules from metadata or defaults
306
        const customObsoletes = metadata?.obsoletes ?? [];
42,961✔
307
        const defaultObsoletes = defaultRule?.obsoletes ?? [];
42,961!
308

309
        const categoriesToObsolete = [... new Set([... customObsoletes, ... defaultObsoletes])];
42,961✔
310
        const shouldObsolete = metadata?.shouldObsolete;
42,961✔
311
        const skipRunning = (metadata?.skipRunning ?? defaultRule?.skipRunning) ?? false;
42,961✔
312
        const respectProgress = (metadata?.respectProgress ?? defaultRule?.respectProgress) ?? true;
42,961!
313

314
        // Check all operations for obsolescence
315
        const allOperations = [
42,961✔
316
            ... this.pendingOperations.values(),
42,961✔
317
            ... this.queuedOperations.values(),
42,961✔
318
            ... (skipRunning ? [] : this.runningOperations.values()),
42,961!
319
        ];
42,961✔
320

321
        for (const operation of allOperations) {
42,961✔
322
            // Skip if it's the same operation
323
            if (operation.id === newOperation.id) {
14,317,312✔
324
                continue;
42,807✔
325
            }
42,807✔
326

327
            let shouldCancel = false;
14,274,505✔
328

329
            // Check category-based obsolescence
330
            if (categoriesToObsolete.includes(operation.category)) {
14,316,972✔
331
                shouldCancel = true;
25,271✔
332
            }
25,271✔
333

334
            // Check custom shouldObsolete function
335
            if (!shouldCancel && shouldObsolete) {
14,317,312!
336
                shouldCancel = shouldObsolete({
3✔
337
                    category: operation.category,
3✔
338
                    id: operation.id,
3✔
339
                    metadata: operation.metadata,
3✔
340
                });
3✔
341
            }
3✔
342

343
            if (shouldCancel) {
14,316,750✔
344
                // Check progress if respectProgress is enabled
345
                if (respectProgress && this.runningOperations.has(operation.id)) {
25,273!
346
                    const progress = this.operationProgress.get(operation.id);
5✔
347
                    if (progress && progress.percent >= PROGRESS_CANCELLATION_THRESHOLD) {
5!
348
                        // Don't cancel near-complete operations
349
                        continue;
×
350
                    }
×
351
                }
5✔
352

353
                // Cancel the operation
354
                const controller = this.activeControllers.get(operation.id);
25,273✔
355
                if (controller && !controller.signal.aborted) {
25,273✔
356
                    controller.abort();
25,235✔
357

358
                    // Emit obsolescence event
359
                    this.eventManager.emitGraphEvent("operation-obsoleted", {
25,235✔
360
                        id: operation.id,
25,235✔
361
                        category: operation.category,
25,235✔
362
                        reason: `Obsoleted by ${newOperation.category} operation`,
25,235✔
363
                        obsoletedBy: newOperation.id,
25,235✔
364
                    });
25,235✔
365

366
                    // Remove from pending/queued
367
                    this.pendingOperations.delete(operation.id);
25,235✔
368
                    this.queuedOperations.delete(operation.id);
25,235✔
369
                    this.currentBatch.delete(operation.id);
25,235✔
370
                }
25,235✔
371
            }
25,273✔
372
        }
14,317,312✔
373
    }
42,961✔
374

375
    /**
376
     * Execute all operations in the current batch
377
     */
378
    private executeBatch(): void {
23✔
379
        // Get all operations in current batch
380
        const batchIds = Array.from(this.currentBatch);
85,833✔
381
        this.currentBatch.clear();
85,833✔
382

383
        const operations = batchIds
85,833✔
384
            .map((id) => this.pendingOperations.get(id))
85,833✔
385
            .filter((op): op is Operation => op !== undefined);
85,833✔
386

387
        if (operations.length > 0) {
85,833✔
388
            this.logger.debug("Executing operation batch", {
42,940✔
389
                operationCount: operations.length,
42,940✔
390
                categories: [... new Set(operations.map((op) => op.category))],
42,940✔
391
            });
42,940✔
392
        }
42,940✔
393

394
        // Remove from pending and add to queued
395
        batchIds.forEach((id) => {
85,833✔
396
            const op = this.pendingOperations.get(id);
43,124✔
397
            if (op) {
43,124✔
398
                this.queuedOperations.set(id, op);
43,124✔
399
            }
43,124✔
400

401
            this.pendingOperations.delete(id);
43,124✔
402
        });
85,833✔
403

404
        if (operations.length === 0) {
85,833✔
405
            return;
42,893✔
406
        }
42,893✔
407

408
        // Sort operations by dependency order
409
        const sortedOperations = this.sortOperations(operations);
42,940✔
410

411
        // Add to queue with p-queue's signal support
412
        for (const operation of sortedOperations) {
43,414✔
413
            // queue.add always returns a promise
414
            void this.queue.add(
43,124✔
415
                async({signal}) => {
43,124✔
416
                    try {
15,753✔
417
                        const context: OperationContext = {
15,753✔
418
                            signal: signal ?? operation.abortController?.signal ?? new AbortController().signal,
15,753!
419
                            progress: this.createProgressContext(operation.id, operation.category),
15,753✔
420
                            id: operation.id,
15,753✔
421
                        };
15,753✔
422
                        await this.executeOperation(operation, context);
15,753✔
423
                    } finally {
15,751✔
424
                        // Cleanup progress and controller after delay
425
                        setTimeout(() => {
15,751✔
426
                            this.operationProgress.delete(operation.id);
13,160✔
427
                            this.activeControllers.delete(operation.id);
13,160✔
428
                        }, CLEANUP_DELAY_MS);
15,751✔
429
                    }
15,751✔
430
                },
15,753✔
431
                {
43,124✔
432
                    signal: operation.abortController?.signal,
43,124✔
433
                },
43,124✔
434
            ).catch((error: unknown) => {
43,124✔
435
                if (error && (error as Error).name === "AbortError") {
25,207✔
436
                    this.eventManager.emitGraphEvent("operation-obsoleted", {
25,207✔
437
                        id: operation.id,
25,207✔
438
                        category: operation.category,
25,207✔
439
                        reason: "Obsoleted by newer operation",
25,207✔
440
                    });
25,207✔
441
                }
25,207✔
442
            });
43,124✔
443
        }
43,124✔
444

445
        // Emit batch complete event after all operations
446
        this.eventManager.emitGraphEvent("operation-batch-complete", {
42,940✔
447
            operationCount: sortedOperations.length,
42,940✔
448
            operations: sortedOperations.map((op) => ({
42,940✔
449
                id: op.id,
43,124✔
450
                category: op.category,
43,124✔
451
                description: op.metadata?.description,
43,124✔
452
            })),
42,940✔
453
        });
42,940✔
454
    }
85,833✔
455

456
    /**
457
     * Sort operations based on category dependencies
458
     * @param operations - Array of operations to sort
459
     * @returns Sorted array of operations in dependency order
460
     */
461
    private sortOperations(operations: Operation[]): Operation[] {
23✔
462
        // Group by category
463
        const operationsByCategory = new Map<OperationCategory, Operation[]>();
42,940✔
464
        operations.forEach((op) => {
42,940✔
465
            const ops = operationsByCategory.get(op.category) ?? [];
43,124✔
466
            ops.push(op);
43,124✔
467
            operationsByCategory.set(op.category, ops);
43,124✔
468
        });
42,940✔
469

470
        // Get unique categories
471
        const categories = Array.from(operationsByCategory.keys());
42,940✔
472

473
        // Build dependency edges for toposort
474
        const edges: [OperationCategory, OperationCategory][] = [];
42,940✔
475
        OperationQueueManager.CATEGORY_DEPENDENCIES.forEach(([dependent, dependency]) => {
42,940✔
476
            // Only add edge if:
477
            // 1. The dependent operation is in this batch
478
            // 2. The dependency hasn't already been completed in a previous batch
479
            // 3. The dependency is either in this batch OR needs to be waited for
480
            if (categories.includes(dependent) && !this.completedCategories.has(dependency)) {
472,340✔
481
                if (categories.includes(dependency)) {
591!
482
                    // Both are in this batch - normal dependency
483
                    edges.push([dependency, dependent]); // toposort expects [from, to]
23✔
484
                }
23✔
485
                // If dependency is not in this batch and not completed, the dependent operation
486
                // may fail or produce incorrect results. In a truly stateless system, we should
487
                // either wait for the dependency or auto-queue it. For now, we allow it to proceed
488
                // and rely on the manager's internal checks (e.g., DataManager checking for styles).
489
            }
591✔
490
        });
42,940✔
491

492
        // Sort categories
493
        let sortedCategories: OperationCategory[];
42,940✔
494
        try {
42,940✔
495
            sortedCategories = toposort.array(categories, edges);
42,940✔
496
        } catch (error) {
42,940!
497
            // Circular dependency detected - emit error event
498
            this.eventManager.emitGraphError(
×
499
                null,
×
500
                error instanceof Error ? error : new Error("Circular dependency detected"),
×
501
                "other",
×
502
                {categories, edges},
×
503
            );
×
504
            sortedCategories = categories; // Fallback to original order
×
505
        }
×
506

507
        // Flatten operations in sorted category order
508
        const sortedOperations: Operation[] = [];
42,940✔
509
        sortedCategories.forEach((category) => {
42,940✔
510
            const categoryOps = operationsByCategory.get(category) ?? [];
42,985!
511
            sortedOperations.push(... categoryOps);
42,985✔
512
        });
42,940✔
513

514
        return sortedOperations;
42,940✔
515
    }
42,940✔
516

517
    /**
518
     * Execute a single operation
519
     * @param operation - The operation to execute
520
     * @param context - Execution context with abort signal and progress tracking
521
     */
522
    private async executeOperation(operation: Operation, context: OperationContext): Promise<void> {
23✔
523
        // Move from queued to running
524
        this.queuedOperations.delete(operation.id);
15,753✔
525
        this.runningOperations.set(operation.id, operation);
15,753✔
526

527
        this.logger.debug("Operation started", {
15,753✔
528
            id: operation.id,
15,753✔
529
            category: operation.category,
15,753✔
530
            description: operation.metadata?.description,
15,753✔
531
        });
15,753✔
532

533
        this.eventManager.emitGraphEvent("operation-start", {
15,753✔
534
            id: operation.id,
15,753✔
535
            category: operation.category,
15,753✔
536
            description: operation.metadata?.description,
15,753✔
537
        });
15,753✔
538

539
        const startTime = performance.now();
15,753✔
540

541
        try {
15,753✔
542
            await operation.execute(context);
15,753✔
543

544
            // Mark as complete
545
            context.progress.setProgress(100);
15,721✔
546

547
            // Mark category as completed for cross-batch dependency resolution
548
            this.completedCategories.add(operation.category);
15,721✔
549

550
            const duration = performance.now() - startTime;
15,721✔
551

552
            this.logger.debug("Operation completed", {
15,721✔
553
                id: operation.id,
15,721✔
554
                category: operation.category,
15,721✔
555
                duration: duration.toFixed(2),
15,721✔
556
            });
15,721✔
557

558
            this.eventManager.emitGraphEvent("operation-complete", {
15,721✔
559
                id: operation.id,
15,721✔
560
                category: operation.category,
15,721✔
561
                duration,
15,721✔
562
            });
15,721✔
563

564
            // Resolve the operation's promise
565
            if (operation.resolve) {
15,727✔
566
                operation.resolve();
15,639✔
567
            }
15,639✔
568

569
            // Trigger post-execution operations if not skipped
570
            if (!operation.metadata?.skipTriggers) {
15,753✔
571
                this.triggerPostExecutionOperations(operation);
15,187✔
572
            }
15,187✔
573
        } catch (error) {
15,753!
574
            if (error && (error as Error).name === "AbortError") {
30!
575
                // Reject the operation's promise
576
                if (operation.reject) {
3!
577
                    operation.reject(error);
×
578
                }
×
579

580
                // Remove from running on abort
581
                this.runningOperations.delete(operation.id);
3✔
582
                throw error; // Let p-queue handle abort errors
3✔
583
            }
3✔
584

585
            // Reject the operation's promise
586
            if (operation.reject) {
27✔
587
                operation.reject(error);
24✔
588
            }
24✔
589

590
            this.handleOperationError(operation, error);
27✔
591
        } finally {
15,753✔
592
            // Always remove from running operations
593
            this.runningOperations.delete(operation.id);
15,751✔
594
        }
15,751✔
595
    }
15,753✔
596

597
    /**
598
     * Create progress context for an operation
599
     * @param id - Unique operation ID
600
     * @param category - Operation category
601
     * @returns Progress context for updating operation progress
602
     */
603
    private createProgressContext(id: string, category: OperationCategory): ProgressContext {
23✔
604
        return {
15,753✔
605
            setProgress: (percent: number) => {
15,753✔
606
                const progress = this.operationProgress.get(id);
15,780✔
607
                if (progress) {
15,780✔
608
                    progress.percent = percent;
15,754✔
609
                    progress.lastUpdate = Date.now();
15,754✔
610
                    this.emitProgressUpdate(id, category, progress);
15,754✔
611
                }
15,754✔
612
            },
15,780✔
613
            setMessage: (message: string) => {
15,753✔
614
                const progress = this.operationProgress.get(id);
75✔
615
                if (progress) {
75✔
616
                    progress.message = message;
75✔
617
                    progress.lastUpdate = Date.now();
75✔
618
                    this.emitProgressUpdate(id, category, progress);
75✔
619
                }
75✔
620
            },
75✔
621
            setPhase: (phase: string) => {
15,753✔
622
                const progress = this.operationProgress.get(id);
3✔
623
                if (progress) {
3✔
624
                    progress.phase = phase;
3✔
625
                    progress.lastUpdate = Date.now();
3✔
626
                    this.emitProgressUpdate(id, category, progress);
3✔
627
                }
3✔
628
            },
3✔
629
        };
15,753✔
630
    }
15,753✔
631

632
    /**
633
     * Emit progress update event
634
     * @param id - Operation ID
635
     * @param category - Operation category
636
     * @param progress - Current progress state
637
     */
638
    private emitProgressUpdate(id: string, category: OperationCategory, progress: OperationProgress): void {
23✔
639
        this.eventManager.emitGraphEvent("operation-progress", {
15,832✔
640
            id,
15,832✔
641
            category,
15,832✔
642
            progress: progress.percent,
15,832✔
643
            message: progress.message,
15,832✔
644
            phase: progress.phase,
15,832✔
645
            duration: Date.now() - progress.startTime,
15,832✔
646
        });
15,832✔
647
    }
15,832✔
648

649
    /**
650
     * Handle operation errors
651
     * @param operation - The operation that failed
652
     * @param error - The error that occurred
653
     */
654
    private handleOperationError(operation: Operation, error: unknown): void {
23✔
655
        this.logger.error(
27✔
656
            "Operation failed",
27✔
657
            error instanceof Error ? error : new Error(String(error)),
27!
658
            {
27✔
659
                id: operation.id,
27✔
660
                category: operation.category,
27✔
661
                description: operation.metadata?.description,
27✔
662
            },
27✔
663
        );
27✔
664

665
        this.eventManager.emitGraphError(
27✔
666
            null,
27✔
667
            error instanceof Error ? error : new Error(String(error)),
27!
668
            "other",
27✔
669
            {
27✔
670
                operationId: operation.id,
27✔
671
                category: operation.category,
27✔
672
                description: operation.metadata?.description,
27✔
673
            },
27✔
674
        );
27✔
675
    }
27✔
676

677
    /**
678
     * Wait for all queued operations to complete
679
     */
680
    async waitForCompletion(): Promise<void> {
23✔
681
        // First, ensure any pending batch is queued
682
        if (this.currentBatch.size > 0) {
432!
683
            this.executeBatch();
32✔
684
        }
32✔
685

686
        // Then wait for queue to be idle
687
        await this.queue.onIdle();
432✔
688
    }
432✔
689

690
    /**
691
     * Get queue statistics
692
     * @returns Current queue state including pending operations, size, and pause status
693
     */
694
    getStats(): {
23✔
695
        pending: number;
696
        size: number;
697
        isPaused: boolean;
698
    } {
4✔
699
        return {
4✔
700
            pending: this.queue.pending,
4✔
701
            size: this.queue.size,
4✔
702
            isPaused: this.queue.isPaused,
4✔
703
        };
4✔
704
    }
4✔
705

706
    /**
707
     * Pause/resume queue execution
708
     */
709
    pause(): void {
23✔
710
        this.queue.pause();
2✔
711
    }
2✔
712

713
    /**
714
     * Resume queue execution after being paused
715
     */
716
    resume(): void {
23✔
717
        this.queue.start();
2✔
718
    }
2✔
719

720
    /**
721
     * Clear all pending operations
722
     */
723
    clear(): void {
23✔
724
        // Cancel all active operations
725
        this.activeControllers.forEach((controller, id) => {
6✔
726
            if (!controller.signal.aborted) {
11✔
727
                controller.abort();
7✔
728
                this.eventManager.emitGraphEvent("operation-cancelled", {
7✔
729
                    id,
7✔
730
                    reason: "Queue cleared",
7✔
731
                });
7✔
732
            }
7✔
733
        });
6✔
734

735
        this.queue.clear();
6✔
736
        this.pendingOperations.clear();
6✔
737
        this.currentBatch.clear();
6✔
738
        this.runningOperations.clear();
6✔
739
        this.queuedOperations.clear();
6✔
740
    }
6✔
741

742
    /**
743
     * Disable batching (execute operations immediately)
744
     */
745
    disableBatching(): void {
23✔
746
        this.batchingEnabled = false;
×
747
    }
×
748

749
    /**
750
     * Enable batching to group operations before execution
751
     */
752
    enableBatching(): void {
23✔
753
        this.batchingEnabled = true;
×
754
        // TODO: Operations queued while batching was disabled will be executed
755
        // when waitForCompletion() is called
756
    }
×
757

758
    /**
759
     * Enter batch mode - operations will be queued but not executed
760
     */
761
    enterBatchMode(): void {
23✔
762
        this.batchMode = true;
36✔
763
        this.batchOperations.clear();
36✔
764
    }
36✔
765

766
    /**
767
     * Exit batch mode - execute all batched operations in dependency order
768
     */
769
    async exitBatchMode(): Promise<void> {
23✔
770
        if (!this.batchMode) {
36!
771
            return;
1✔
772
        }
1✔
773

774
        this.batchMode = false;
35✔
775

776
        // Move all batched operations to currentBatch
777
        this.batchOperations.forEach((id) => {
35✔
778
            this.currentBatch.add(id);
177✔
779
        });
35✔
780

781
        // Collect all batch promises
782
        const promises = Array.from(this.batchPromises.values());
35✔
783

784
        // Clear batch tracking
785
        this.batchOperations.clear();
35✔
786
        this.batchPromises.clear();
35✔
787

788
        // Execute the batch
789
        this.executeBatch();
35✔
790

791
        // Wait for all operations to complete
792
        // Use allSettled to handle both resolved and rejected promises
793
        // Individual operation errors are already handled via handleOperationError
794
        await Promise.allSettled(promises);
35✔
795
    }
36✔
796

797
    /**
798
     * Check if currently in batch mode
799
     * @returns True if in batch mode, false otherwise
800
     */
801
    isInBatchMode(): boolean {
23✔
802
        return this.batchMode;
10✔
803
    }
10✔
804

805
    /**
806
     * Queue an operation and get a promise for its completion
807
     * Used for batch mode operations
808
     * @param category - Category of the operation
809
     * @param execute - Function to execute for this operation
810
     * @param options - Optional metadata for the operation
811
     * @returns Promise that resolves when the operation completes
812
     */
813
    queueOperationAsync(
23✔
814
        category: OperationCategory,
43,026✔
815
        execute: (context: OperationContext) => Promise<void> | void,
43,026✔
816
        options?: Partial<OperationMetadata>,
43,026✔
817
    ): Promise<void> {
43,026✔
818
        const id = this.queueOperation(category, execute, options);
43,026✔
819

820
        // Create promise that resolves when this specific operation completes
821
        const promise = new Promise<void>((resolve, reject) => {
43,026✔
822
            // Store resolvers with the operation
823
            const operation = this.pendingOperations.get(id);
43,026✔
824
            if (operation) {
43,026✔
825
                operation.resolve = resolve;
43,026✔
826
                operation.reject = reject;
43,026✔
827
            }
43,026✔
828
        });
43,026✔
829

830
        if (this.batchMode) {
43,026!
831
            // In batch mode, track the promise for later
832
            this.batchPromises.set(id, promise);
177✔
833
            // Return immediately resolved promise to avoid deadlock
834
            // The actual operation will execute when exitBatchMode is called
835
            return Promise.resolve();
177✔
836
        }
177✔
837

838
        // Not in batch mode, execute normally
839
        this.executeBatch();
42,849✔
840
        return promise;
42,849✔
841
    }
43,026✔
842

843
    /**
844
     * Wait for a specific operation to complete
845
     * @param id - Operation ID to wait for
846
     */
847
    private async waitForOperation(id: string): Promise<void> {
23✔
848
        while (
×
849
            this.pendingOperations.has(id) ||
×
850
            this.queuedOperations.has(id) ||
×
851
            this.runningOperations.has(id)
×
852
        ) {
×
853
            await new Promise((resolve) => setTimeout(resolve, OPERATION_POLL_INTERVAL_MS));
×
854
        }
×
855
    }
×
856

857
    /**
858
     * Get the AbortController for a specific operation
859
     * @param operationId - ID of the operation
860
     * @returns The AbortController or undefined if not found
861
     */
862
    getOperationController(operationId: string): AbortController | undefined {
23✔
863
        return this.activeControllers.get(operationId);
×
864
    }
×
865

866
    /**
867
     * Cancel a specific operation
868
     * @param operationId - ID of the operation to cancel
869
     * @returns True if the operation was cancelled, false otherwise
870
     */
871
    cancelOperation(operationId: string): boolean {
23✔
872
        const controller = this.activeControllers.get(operationId);
×
873
        if (controller && !controller.signal.aborted) {
×
874
            controller.abort();
×
875

876
            // Emit cancellation event
877
            this.eventManager.emitGraphEvent("operation-cancelled", {
×
878
                id: operationId,
×
879
                reason: "Manual cancellation",
×
880
            });
×
881

882
            return true;
×
883
        }
×
884

885
        return false;
×
886
    }
×
887

888
    /**
889
     * Mark a category as completed (for satisfying cross-batch dependencies)
890
     * This is useful when a category's requirements are met through other means
891
     * (e.g., style-init is satisfied by constructor initialization)
892
     * @param category - The operation category to mark as completed
893
     */
894
    markCategoryCompleted(category: OperationCategory): void {
23✔
895
        this.completedCategories.add(category);
919✔
896
    }
919✔
897

898
    /**
899
     * Clear completed status for a category
900
     * This is useful when a category needs to be re-executed
901
     * (e.g., setStyleTemplate is called explicitly, overriding initial styles)
902
     * @param category - The operation category to clear
903
     */
904
    clearCategoryCompleted(category: OperationCategory): void {
23✔
905
        this.completedCategories.delete(category);
658✔
906
    }
658✔
907

908
    /**
909
     * Cancel all operations of a specific category
910
     * @param category - The operation category to cancel
911
     * @returns Number of operations cancelled
912
     */
913
    cancelByCategory(category: OperationCategory): number {
23✔
914
        let cancelledCount = 0;
×
915

916
        // Cancel pending operations
917
        this.pendingOperations.forEach((operation) => {
×
918
            if (operation.category === category) {
×
919
                if (this.cancelOperation(operation.id)) {
×
920
                    cancelledCount++;
×
921
                }
×
922
            }
×
923
        });
×
924

925
        return cancelledCount;
×
926
    }
×
927

928
    /**
929
     * Get current progress for an operation
930
     * @param operationId - ID of the operation
931
     * @returns Progress information or undefined if not found
932
     */
933
    getOperationProgress(operationId: string): OperationProgress | undefined {
23✔
934
        return this.operationProgress.get(operationId);
×
935
    }
×
936

937
    /**
938
     * Get all active operation IDs
939
     * @returns Array of active operation IDs
940
     */
941
    getActiveOperations(): string[] {
23✔
942
        return Array.from(this.activeControllers.keys());
×
943
    }
×
944

945
    /**
946
     * Register a custom trigger for a specific operation category
947
     * @param category - Operation category to trigger on
948
     * @param trigger - Function that returns trigger configuration or null
949
     */
950
    registerTrigger(
23✔
951
        category: OperationCategory,
996✔
952
        trigger: (metadata?: OperationMetadata) => {
996✔
953
            category: OperationCategory;
954
            execute: (context: OperationContext) => Promise<void> | void;
955
            description?: string;
956
        } | null,
957
    ): void {
996✔
958
        if (!this.triggers.has(category)) {
996✔
959
            this.triggers.set(category, []);
995✔
960
        }
995✔
961

962
        const triggerArray = this.triggers.get(category);
996✔
963
        if (triggerArray) {
996✔
964
            triggerArray.push(trigger);
996✔
965
        }
996✔
966
    }
996✔
967

968
    /**
969
     * Trigger post-execution operations based on the completed operation
970
     * @param operation - The completed operation that may trigger other operations
971
     */
972
    private triggerPostExecutionOperations(operation: Operation): void {
23✔
973
        // Check for default triggers
974
        const defaultTriggers = OperationQueueManager.POST_EXECUTION_TRIGGERS[operation.category];
15,187✔
975

976
        // Check for custom triggers
977
        const customTriggers = this.triggers.get(operation.category) ?? [];
15,187✔
978

979
        // Process default triggers
980
        if (defaultTriggers) {
15,187✔
981
            for (const triggerCategory of defaultTriggers) {
12,915✔
982
                // Check prerequisites
983
                if (triggerCategory === "layout-update" && this.hasLayoutEngine && !this.hasLayoutEngine()) {
12,915!
984
                    continue; // Skip if no layout engine
2✔
985
                }
2✔
986

987
                // Queue the triggered operation
988
                void this.queueTriggeredOperation(triggerCategory, operation.metadata);
12,913✔
989
            }
12,913✔
990
        }
12,915✔
991

992
        // Process custom triggers
993
        for (const trigger of customTriggers) {
15,187✔
994
            const result = trigger(operation.metadata);
12,775✔
995
            if (result) {
12,775✔
996
                // Queue the custom triggered operation
997
                void this.queueTriggeredOperation(
12,773✔
998
                    result.category,
12,773✔
999
                    operation.metadata,
12,773✔
1000
                    result.execute,
12,773✔
1001
                    result.description,
12,773✔
1002
                );
12,773✔
1003
            }
12,773✔
1004
        }
12,775✔
1005
    }
15,187✔
1006

1007
    /**
1008
     * Queue a triggered operation
1009
     * @param category - Category of the triggered operation
1010
     * @param sourceMetadata - Metadata from the source operation
1011
     * @param execute - Optional execution function
1012
     * @param description - Optional description of the operation
1013
     */
1014
    private async queueTriggeredOperation(
23✔
1015
        category: OperationCategory,
25,686✔
1016
        sourceMetadata?: OperationMetadata,
25,686✔
1017
        execute?: (context: OperationContext) => Promise<void> | void,
25,686✔
1018
        description?: string,
25,686✔
1019
    ): Promise<void> {
25,686✔
1020
        // Notify test callback if set
1021
        if (this.onOperationQueued) {
25,686!
1022
            this.onOperationQueued(category, description);
4✔
1023
        }
4✔
1024

1025
        // Default execute function for layout-update
1026
        if (!execute && category === "layout-update") {
25,686✔
1027
            execute = (context: OperationContext) => {
12,845✔
1028
                // This will be implemented by the Graph/LayoutManager
1029
                context.progress.setMessage("Updating layout positions");
48✔
1030
            };
48✔
1031
        }
12,845✔
1032

1033
        if (!execute) {
25,686!
1034
            return; // No execute function provided
68✔
1035
        }
68✔
1036

1037
        // Queue the operation
1038
        await this.queueOperationAsync(
25,618✔
1039
            category,
25,618✔
1040
            execute,
25,618✔
1041
            {
25,618✔
1042
                description: description ?? `Triggered ${category}`,
25,638✔
1043
                source: "trigger",
25,686✔
1044
                skipTriggers: true, // Prevent trigger loops
25,686✔
1045
            },
25,686✔
1046
        );
25,686✔
1047
    }
25,686✔
1048
}
23✔
1049

STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc