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

graphty-org / graphty-element / 20125394108

11 Dec 2025 07:28AM UTC coverage: 86.391% (-0.02%) from 86.407%
20125394108

push

github

apowers313
ci: setup trusted publishing, fix tests

3692 of 4322 branches covered (85.42%)

Branch coverage included in aggregate %.

23 of 23 new or added lines in 2 files covered. (100.0%)

2 existing lines in 1 file now uncovered.

17441 of 20140 relevant lines covered (86.6%)

8665.05 hits per line

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

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

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

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

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

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

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

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

48
export class OperationQueueManager implements Manager {
3✔
49
    private queue: PQueue;
50
    private pendingOperations = new Map<string, Operation>();
11✔
51
    private operationCounter = 0;
11✔
52
    private batchingEnabled = true;
11✔
53
    private currentBatch = new Set<string>();
11✔
54

55
    // Progress tracking
56
    private operationProgress = new Map<string, OperationProgress>();
11✔
57

58
    // Active operation controllers for cancellation
59
    private activeControllers = new Map<string, AbortController>();
11✔
60

61
    // Track running vs queued operations
62
    private runningOperations = new Map<string, Operation>();
11✔
63
    private queuedOperations = new Map<string, Operation>();
11✔
64

65
    // Track completed operation categories for cross-batch dependency resolution
66
    private completedCategories = new Set<OperationCategory>();
11✔
67

68
    // Batch mode support
69
    private batchMode = false;
11✔
70
    private batchOperations = new Set<string>();
11✔
71
    private batchPromises = new Map<string, Promise<void>>();
11✔
72

73
    // Trigger system
74
    private triggers = new Map<OperationCategory, ((metadata?: OperationMetadata) => {
11✔
75
        category: OperationCategory;
76
        execute: (context: OperationContext) => Promise<void> | void;
77
        description?: string;
78
    } | null)[]>();
11✔
79

80
    // Callback for when operations are queued (for testing)
81
    onOperationQueued?: (category: OperationCategory, description?: string) => void;
82

83
    // Check if layout engine exists (will be set by Graph)
84
    hasLayoutEngine?: () => boolean;
85

86
    // Dependency graph based on actual manager dependencies
87
    private static readonly CATEGORY_DEPENDENCIES: [OperationCategory, OperationCategory][] = [
11✔
88
        // Style must be initialized before applying
89
        ["style-apply", "style-init"],
11✔
90

91
        // Data operations depend on style being ready
92
        ["data-add", "style-init"],
11✔
93
        ["data-update", "style-init"],
11✔
94

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

99
        // Layout-update DOES depend on data existing
100
        ["layout-update", "data-add"],
11✔
101
        // Layout-update depends on layout being set first
102
        ["layout-update", "layout-set"],
11✔
103

104
        // Algorithms depend on data
105
        ["algorithm-run", "data-add"],
11✔
106

107
        // Style application depends on algorithms (for calculated styles)
108
        ["style-apply", "algorithm-run"],
11✔
109

110
        // Camera updates may depend on layout for zoom-to-fit
111
        ["camera-update", "layout-set"],
11✔
112

113
        // Render updates come last
114
        ["render-update", "style-apply"],
11✔
115
        ["render-update", "data-add"],
11✔
116
        ["render-update", "layout-update"],
11✔
117
    ];
11✔
118

119
    // Post-execution triggers: operations that automatically trigger after other operations
120
    private static readonly POST_EXECUTION_TRIGGERS: Partial<Record<OperationCategory, OperationCategory[]>> = {
11✔
121
        "data-add": ["layout-update"],
11✔
122
        "data-remove": ["layout-update"],
11✔
123
        "data-update": ["layout-update"],
11✔
124
        "algorithm-run": ["style-apply"],
11✔
125
    };
11✔
126

127
    constructor(
11✔
128
        private eventManager: EventManager,
699✔
129
        options: {
699✔
130
            concurrency?: number;
131
            autoStart?: boolean;
132
            intervalCap?: number;
133
            interval?: number;
134
        } = {},
699✔
135
    ) {
699✔
136
        const queueOptions: {
699✔
137
            concurrency?: number;
138
            autoStart?: boolean;
139
            intervalCap?: number;
140
            interval?: number;
141
        } = {
699✔
142
            concurrency: options.concurrency ?? 1, // Sequential by default
699✔
143
            autoStart: options.autoStart ?? true,
699✔
144
        };
699✔
145

146
        if (options.intervalCap !== undefined) {
699!
147
            queueOptions.intervalCap = options.intervalCap;
×
148
        }
×
149

150
        if (options.interval !== undefined) {
699!
151
            queueOptions.interval = options.interval;
×
152
        }
×
153

154
        this.queue = new PQueue(queueOptions);
699✔
155

156
        // Listen for queue events
157
        this.queue.on("active", () => {
699✔
158
            this.eventManager.emitGraphEvent("operation-queue-active", {
38,555✔
159
                size: this.queue.size,
38,555✔
160
                pending: this.queue.pending,
38,555✔
161
            });
38,555✔
162
        });
699✔
163

164
        this.queue.on("idle", () => {
699✔
165
            this.eventManager.emitGraphEvent("operation-queue-idle", {});
1,304✔
166
        });
699✔
167
    }
699✔
168

169
    async init(): Promise<void> {
11✔
170
        // No initialization needed
171
    }
582✔
172

173
    dispose(): void {
11✔
174
        // Cancel all active operations
175
        this.activeControllers.forEach((controller) => {
530✔
176
            controller.abort();
30,139✔
177
        });
530✔
178

179
        this.queue.clear();
530✔
180
        this.pendingOperations.clear();
530✔
181
        this.operationProgress.clear();
530✔
182
        this.currentBatch.clear();
530✔
183
        this.activeControllers.clear();
530✔
184
        this.runningOperations.clear();
530✔
185
        this.queuedOperations.clear();
530✔
186
    }
530✔
187

188
    /**
189
     * Queue an operation for execution
190
     * Returns the operation ID
191
     */
192
    queueOperation(
11✔
193
        category: OperationCategory,
40,856✔
194
        execute: (context: OperationContext) => Promise<void> | void,
40,856✔
195
        options?: Partial<OperationMetadata>,
40,856✔
196
    ): string {
40,856✔
197
        const id = `op-${this.operationCounter++}`;
40,856✔
198
        const controller = new AbortController();
40,856✔
199

200
        // Initialize progress tracking with all fields
201
        this.operationProgress.set(id, {
40,856✔
202
            percent: 0,
40,856✔
203
            message: undefined,
40,856✔
204
            phase: undefined,
40,856✔
205
            startTime: Date.now(),
40,856✔
206
            lastUpdate: Date.now(),
40,856✔
207
        });
40,856✔
208

209
        const operation: Operation = {
40,856✔
210
            id,
40,856✔
211
            category,
40,856✔
212
            execute: async(ctx) => {
40,856✔
213
                const result = execute(ctx);
14,265✔
214
                if (result instanceof Promise) {
14,265✔
215
                    await result;
1,841✔
216
                }
1,822✔
217
            },
14,265✔
218
            abortController: controller,
40,856✔
219
            metadata: {
40,856✔
220
                ... options,
40,856✔
221
                timestamp: Date.now(),
40,856✔
222
            },
40,856✔
223
        };
40,856✔
224

225
        this.pendingOperations.set(id, operation);
40,856✔
226
        this.activeControllers.set(id, controller);
40,856✔
227

228
        // Handle batch mode differently
229
        if (this.batchMode) {
40,856✔
230
            // In batch mode: queue but don't execute yet
231
            this.batchOperations.add(id);
177✔
232
        } else {
40,856✔
233
            // Normal mode: add to current batch for immediate execution
234
            this.currentBatch.add(id);
40,679✔
235

236
            // Apply obsolescence rules before scheduling
237
            this.applyObsolescenceRules(operation);
40,679✔
238

239
            // Schedule batch execution
240
            if (this.batchingEnabled) {
40,679✔
241
                // When batching is enabled, schedule batch execution on next microtask
242
                if (this.currentBatch.size === 1) {
40,679✔
243
                    queueMicrotask(() => {
40,635✔
244
                        this.executeBatch();
40,635✔
245
                    });
40,635✔
246
                }
40,635✔
247
            } else {
40,679!
248
                // When batching is disabled, execute immediately
249
                queueMicrotask(() => {
×
250
                    this.executeBatch();
×
251
                });
×
252
            }
×
253
        }
40,679✔
254

255
        return id;
40,856✔
256
    }
40,856✔
257

258
    /**
259
     * Apply obsolescence rules for a new operation
260
     */
261
    private applyObsolescenceRules(newOperation: Operation): void {
11✔
262
        const {metadata} = newOperation;
40,679✔
263
        const defaultRule = OBSOLESCENCE_RULES[newOperation.category];
40,679✔
264
        // Only apply obsolescence if explicitly requested via metadata or default rules
265
        if (!metadata?.obsoletes &&
40,679✔
266
            !metadata?.shouldObsolete &&
40,667✔
267
            !metadata?.respectProgress &&
40,662✔
268
            !metadata?.skipRunning &&
40,662✔
269
            !defaultRule?.obsoletes) {
40,679✔
270
            return;
154✔
271
        }
154✔
272

273
        // Get obsolescence rules from metadata or defaults
274
        const customObsoletes = metadata?.obsoletes ?? [];
40,679✔
275
        const defaultObsoletes = defaultRule?.obsoletes ?? [];
40,679✔
276

277
        const categoriesToObsolete = [... new Set([... customObsoletes, ... defaultObsoletes])];
40,679✔
278
        const shouldObsolete = metadata?.shouldObsolete;
40,679✔
279
        const skipRunning = (metadata?.skipRunning ?? defaultRule?.skipRunning) ?? false;
40,679✔
280
        const respectProgress = (metadata?.respectProgress ?? defaultRule?.respectProgress) ?? true;
40,679✔
281

282
        // Check all operations for obsolescence
283
        const allOperations = [
40,679✔
284
            ... this.pendingOperations.values(),
40,679✔
285
            ... this.queuedOperations.values(),
40,679✔
286
            ... (skipRunning ? [] : this.runningOperations.values()),
40,679✔
287
        ];
40,679✔
288

289
        for (const operation of allOperations) {
40,679✔
290
            // Skip if it's the same operation
291
            if (operation.id === newOperation.id) {
14,313,256✔
292
                continue;
40,525✔
293
            }
40,525✔
294

295
            let shouldCancel = false;
14,272,731✔
296

297
            // Check category-based obsolescence
298
            if (categoriesToObsolete.includes(operation.category)) {
14,313,256✔
299
                shouldCancel = true;
24,474✔
300
            }
24,474✔
301

302
            // Check custom shouldObsolete function
303
            if (!shouldCancel && shouldObsolete) {
14,313,256✔
304
                shouldCancel = shouldObsolete({
3✔
305
                    category: operation.category,
3✔
306
                    id: operation.id,
3✔
307
                    metadata: operation.metadata,
3✔
308
                });
3✔
309
            }
3✔
310

311
            if (shouldCancel) {
14,312,851✔
312
                // Check progress if respectProgress is enabled
313
                if (respectProgress && this.runningOperations.has(operation.id)) {
24,476✔
314
                    const progress = this.operationProgress.get(operation.id);
5✔
315
                    if (progress && progress.percent >= PROGRESS_CANCELLATION_THRESHOLD) {
5!
316
                        // Don't cancel near-complete operations
UNCOV
317
                        continue;
×
UNCOV
318
                    }
×
319
                }
5✔
320

321
                // Cancel the operation
322
                const controller = this.activeControllers.get(operation.id);
24,476✔
323
                if (controller && !controller.signal.aborted) {
24,476✔
324
                    controller.abort();
24,439✔
325

326
                    // Emit obsolescence event
327
                    this.eventManager.emitGraphEvent("operation-obsoleted", {
24,439✔
328
                        id: operation.id,
24,439✔
329
                        category: operation.category,
24,439✔
330
                        reason: `Obsoleted by ${newOperation.category} operation`,
24,439✔
331
                        obsoletedBy: newOperation.id,
24,439✔
332
                    });
24,439✔
333

334
                    // Remove from pending/queued
335
                    this.pendingOperations.delete(operation.id);
24,439✔
336
                    this.queuedOperations.delete(operation.id);
24,439✔
337
                    this.currentBatch.delete(operation.id);
24,439✔
338
                }
24,439✔
339
            }
24,476✔
340
        }
14,313,256✔
341
    }
40,679✔
342

343
    /**
344
     * Execute all operations in the current batch
345
     */
346
    private executeBatch(): void {
11✔
347
        // Get all operations in current batch
348
        const batchIds = Array.from(this.currentBatch);
81,269✔
349
        this.currentBatch.clear();
81,269✔
350

351
        const operations = batchIds
81,269✔
352
            .map((id) => this.pendingOperations.get(id))
81,269✔
353
            .filter((op): op is Operation => op !== undefined);
81,269✔
354

355
        // Remove from pending and add to queued
356
        batchIds.forEach((id) => {
81,269✔
357
            const op = this.pendingOperations.get(id);
40,842✔
358
            if (op) {
40,842✔
359
                this.queuedOperations.set(id, op);
40,842✔
360
            }
40,842✔
361

362
            this.pendingOperations.delete(id);
40,842✔
363
        });
81,269✔
364

365
        if (operations.length === 0) {
81,269✔
366
            return;
40,611✔
367
        }
40,611✔
368

369
        // Sort operations by dependency order
370
        const sortedOperations = this.sortOperations(operations);
40,658✔
371

372
        // Add to queue with p-queue's signal support
373
        for (const operation of sortedOperations) {
41,132✔
374
            // queue.add always returns a promise
375
            void this.queue.add(
40,842✔
376
                async({signal}) => {
40,842✔
377
                    try {
14,265✔
378
                        const context: OperationContext = {
14,265✔
379
                            signal: signal ?? operation.abortController?.signal ?? new AbortController().signal,
14,265!
380
                            progress: this.createProgressContext(operation.id, operation.category),
14,265✔
381
                            id: operation.id,
14,265✔
382
                        };
14,265✔
383
                        await this.executeOperation(operation, context);
14,265✔
384
                    } finally {
14,265✔
385
                        // Cleanup progress and controller after delay
386
                        setTimeout(() => {
14,265✔
387
                            this.operationProgress.delete(operation.id);
13,745✔
388
                            this.activeControllers.delete(operation.id);
13,745✔
389
                        }, CLEANUP_DELAY_MS);
14,265✔
390
                    }
14,265✔
391
                },
14,265✔
392
                {
40,842✔
393
                    signal: operation.abortController?.signal,
40,842✔
394
                },
40,842✔
395
            ).catch((error: unknown) => {
40,842✔
396
                if (error && (error as Error).name === "AbortError") {
24,411✔
397
                    this.eventManager.emitGraphEvent("operation-obsoleted", {
24,411✔
398
                        id: operation.id,
24,411✔
399
                        category: operation.category,
24,411✔
400
                        reason: "Obsoleted by newer operation",
24,411✔
401
                    });
24,411✔
402
                }
24,411✔
403
            });
40,842✔
404
        }
40,842✔
405

406
        // Emit batch complete event after all operations
407
        this.eventManager.emitGraphEvent("operation-batch-complete", {
40,658✔
408
            operationCount: sortedOperations.length,
40,658✔
409
            operations: sortedOperations.map((op) => ({
40,658✔
410
                id: op.id,
40,842✔
411
                category: op.category,
40,842✔
412
                description: op.metadata?.description,
40,842✔
413
            })),
40,658✔
414
        });
40,658✔
415
    }
81,269✔
416

417
    /**
418
     * Sort operations based on category dependencies
419
     */
420
    private sortOperations(operations: Operation[]): Operation[] {
11✔
421
        // Group by category
422
        const operationsByCategory = new Map<OperationCategory, Operation[]>();
40,658✔
423
        operations.forEach((op) => {
40,658✔
424
            const ops = operationsByCategory.get(op.category) ?? [];
40,842✔
425
            ops.push(op);
40,842✔
426
            operationsByCategory.set(op.category, ops);
40,842✔
427
        });
40,658✔
428

429
        // Get unique categories
430
        const categories = Array.from(operationsByCategory.keys());
40,658✔
431

432
        // Build dependency edges for toposort
433
        const edges: [OperationCategory, OperationCategory][] = [];
40,658✔
434
        OperationQueueManager.CATEGORY_DEPENDENCIES.forEach(([dependent, dependency]) => {
40,658✔
435
            // Only add edge if:
436
            // 1. The dependent operation is in this batch
437
            // 2. The dependency hasn't already been completed in a previous batch
438
            // 3. The dependency is either in this batch OR needs to be waited for
439
            if (categories.includes(dependent) && !this.completedCategories.has(dependency)) {
447,238✔
440
                if (categories.includes(dependency)) {
597✔
441
                    // Both are in this batch - normal dependency
442
                    edges.push([dependency, dependent]); // toposort expects [from, to]
24✔
443
                }
24✔
444
                // If dependency is not in this batch and not completed, the dependent operation
445
                // may fail or produce incorrect results. In a truly stateless system, we should
446
                // either wait for the dependency or auto-queue it. For now, we allow it to proceed
447
                // and rely on the manager's internal checks (e.g., DataManager checking for styles).
448
            }
597✔
449
        });
40,658✔
450

451
        // Sort categories
452
        let sortedCategories: OperationCategory[];
40,658✔
453
        try {
40,658✔
454
            sortedCategories = toposort.array(categories, edges);
40,658✔
455
        } catch (error) {
40,658!
456
            // Circular dependency detected - emit error event
457
            this.eventManager.emitGraphError(
×
458
                null,
×
459
                error instanceof Error ? error : new Error("Circular dependency detected"),
×
460
                "other",
×
461
                {categories, edges},
×
462
            );
×
463
            sortedCategories = categories; // Fallback to original order
×
464
        }
×
465

466
        // Flatten operations in sorted category order
467
        const sortedOperations: Operation[] = [];
40,658✔
468
        sortedCategories.forEach((category) => {
40,658✔
469
            const categoryOps = operationsByCategory.get(category) ?? [];
40,703!
470
            sortedOperations.push(... categoryOps);
40,703✔
471
        });
40,658✔
472

473
        return sortedOperations;
40,658✔
474
    }
40,658✔
475

476
    /**
477
     * Execute a single operation
478
     */
479
    private async executeOperation(operation: Operation, context: OperationContext): Promise<void> {
11✔
480
        // Move from queued to running
481
        this.queuedOperations.delete(operation.id);
14,265✔
482
        this.runningOperations.set(operation.id, operation);
14,265✔
483

484
        this.eventManager.emitGraphEvent("operation-start", {
14,265✔
485
            id: operation.id,
14,265✔
486
            category: operation.category,
14,265✔
487
            description: operation.metadata?.description,
14,265✔
488
        });
14,265✔
489

490
        const startTime = performance.now();
14,265✔
491

492
        try {
14,265✔
493
            await operation.execute(context);
14,265✔
494

495
            // Mark as complete
496
            context.progress.setProgress(100);
14,242✔
497

498
            // Mark category as completed for cross-batch dependency resolution
499
            this.completedCategories.add(operation.category);
14,242✔
500

501
            const duration = performance.now() - startTime;
14,242✔
502
            this.eventManager.emitGraphEvent("operation-complete", {
14,242✔
503
                id: operation.id,
14,242✔
504
                category: operation.category,
14,242✔
505
                duration,
14,242✔
506
            });
14,242✔
507

508
            // Resolve the operation's promise
509
            if (operation.resolve) {
14,248✔
510
                operation.resolve();
14,160✔
511
            }
14,160✔
512

513
            // Trigger post-execution operations if not skipped
514
            if (!operation.metadata?.skipTriggers) {
14,265✔
515
                this.triggerPostExecutionOperations(operation);
13,918✔
516
            }
13,918✔
517
        } catch (error) {
14,265✔
518
            if (error && (error as Error).name === "AbortError") {
23✔
519
                // Reject the operation's promise
520
                if (operation.reject) {
3!
521
                    operation.reject(error);
×
522
                }
×
523

524
                // Remove from running on abort
525
                this.runningOperations.delete(operation.id);
3✔
526
                throw error; // Let p-queue handle abort errors
3✔
527
            }
3✔
528

529
            // Reject the operation's promise
530
            if (operation.reject) {
20✔
531
                operation.reject(error);
17✔
532
            }
17✔
533

534
            this.handleOperationError(operation, error);
20✔
535
        } finally {
14,265✔
536
            // Always remove from running operations
537
            this.runningOperations.delete(operation.id);
14,265✔
538
        }
14,265✔
539
    }
14,265✔
540

541
    /**
542
     * Create progress context for an operation
543
     */
544
    private createProgressContext(id: string, category: OperationCategory): ProgressContext {
11✔
545
        return {
14,265✔
546
            setProgress: (percent: number) => {
14,265✔
547
                const progress = this.operationProgress.get(id);
14,301✔
548
                if (progress) {
14,301✔
549
                    progress.percent = percent;
14,273✔
550
                    progress.lastUpdate = Date.now();
14,273✔
551
                    this.emitProgressUpdate(id, category, progress);
14,273✔
552
                }
14,273✔
553
            },
14,301✔
554
            setMessage: (message: string) => {
14,265✔
555
                const progress = this.operationProgress.get(id);
74✔
556
                if (progress) {
74✔
557
                    progress.message = message;
74✔
558
                    progress.lastUpdate = Date.now();
74✔
559
                    this.emitProgressUpdate(id, category, progress);
74✔
560
                }
74✔
561
            },
74✔
562
            setPhase: (phase: string) => {
14,265✔
563
                const progress = this.operationProgress.get(id);
3✔
564
                if (progress) {
3✔
565
                    progress.phase = phase;
3✔
566
                    progress.lastUpdate = Date.now();
3✔
567
                    this.emitProgressUpdate(id, category, progress);
3✔
568
                }
3✔
569
            },
3✔
570
        };
14,265✔
571
    }
14,265✔
572

573
    /**
574
     * Emit progress update event
575
     */
576
    private emitProgressUpdate(id: string, category: OperationCategory, progress: OperationProgress): void {
11✔
577
        this.eventManager.emitGraphEvent("operation-progress", {
14,350✔
578
            id,
14,350✔
579
            category,
14,350✔
580
            progress: progress.percent,
14,350✔
581
            message: progress.message,
14,350✔
582
            phase: progress.phase,
14,350✔
583
            duration: Date.now() - progress.startTime,
14,350✔
584
        });
14,350✔
585
    }
14,350✔
586

587
    /**
588
     * Handle operation errors
589
     */
590
    private handleOperationError(operation: Operation, error: unknown): void {
11✔
591
        this.eventManager.emitGraphError(
20✔
592
            null,
20✔
593
            error instanceof Error ? error : new Error(String(error)),
20!
594
            "other",
20✔
595
            {
20✔
596
                operationId: operation.id,
20✔
597
                category: operation.category,
20✔
598
                description: operation.metadata?.description,
20✔
599
            },
20✔
600
        );
20✔
601
    }
20✔
602

603
    /**
604
     * Wait for all queued operations to complete
605
     */
606
    async waitForCompletion(): Promise<void> {
11✔
607
        // First, ensure any pending batch is queued
608
        if (this.currentBatch.size > 0) {
267✔
609
            this.executeBatch();
32✔
610
        }
32✔
611

612
        // Then wait for queue to be idle
613
        await this.queue.onIdle();
267✔
614
    }
267✔
615

616
    /**
617
     * Get queue statistics
618
     */
619
    getStats(): {
11✔
620
        pending: number;
621
        size: number;
622
        isPaused: boolean;
623
    } {
4✔
624
        return {
4✔
625
            pending: this.queue.pending,
4✔
626
            size: this.queue.size,
4✔
627
            isPaused: this.queue.isPaused,
4✔
628
        };
4✔
629
    }
4✔
630

631
    /**
632
     * Pause/resume queue execution
633
     */
634
    pause(): void {
11✔
635
        this.queue.pause();
2✔
636
    }
2✔
637

638
    resume(): void {
11✔
639
        this.queue.start();
2✔
640
    }
2✔
641

642
    /**
643
     * Clear all pending operations
644
     */
645
    clear(): void {
11✔
646
        // Cancel all active operations
647
        this.activeControllers.forEach((controller, id) => {
6✔
648
            if (!controller.signal.aborted) {
11✔
649
                controller.abort();
7✔
650
                this.eventManager.emitGraphEvent("operation-cancelled", {
7✔
651
                    id,
7✔
652
                    reason: "Queue cleared",
7✔
653
                });
7✔
654
            }
7✔
655
        });
6✔
656

657
        this.queue.clear();
6✔
658
        this.pendingOperations.clear();
6✔
659
        this.currentBatch.clear();
6✔
660
        this.runningOperations.clear();
6✔
661
        this.queuedOperations.clear();
6✔
662
    }
6✔
663

664
    /**
665
     * Disable batching (execute operations immediately)
666
     */
667
    disableBatching(): void {
11✔
668
        this.batchingEnabled = false;
×
669
    }
×
670

671
    enableBatching(): void {
11✔
672
        this.batchingEnabled = true;
×
673
        // TODO: Operations queued while batching was disabled will be executed
674
        // when waitForCompletion() is called
675
    }
×
676

677
    /**
678
     * Enter batch mode - operations will be queued but not executed
679
     */
680
    enterBatchMode(): void {
11✔
681
        this.batchMode = true;
36✔
682
        this.batchOperations.clear();
36✔
683
    }
36✔
684

685
    /**
686
     * Exit batch mode - execute all batched operations in dependency order
687
     */
688
    async exitBatchMode(): Promise<void> {
11✔
689
        if (!this.batchMode) {
36✔
690
            return;
1✔
691
        }
1✔
692

693
        this.batchMode = false;
35✔
694

695
        // Move all batched operations to currentBatch
696
        this.batchOperations.forEach((id) => {
35✔
697
            this.currentBatch.add(id);
177✔
698
        });
35✔
699

700
        // Collect all batch promises
701
        const promises = Array.from(this.batchPromises.values());
35✔
702

703
        // Clear batch tracking
704
        this.batchOperations.clear();
35✔
705
        this.batchPromises.clear();
35✔
706

707
        // Execute the batch
708
        this.executeBatch();
35✔
709

710
        // Wait for all operations to complete
711
        // Use allSettled to handle both resolved and rejected promises
712
        // Individual operation errors are already handled via handleOperationError
713
        await Promise.allSettled(promises);
35✔
714
    }
36✔
715

716
    /**
717
     * Check if currently in batch mode
718
     */
719
    isInBatchMode(): boolean {
11✔
720
        return this.batchMode;
10✔
721
    }
10✔
722

723
    /**
724
     * Queue an operation and get a promise for its completion
725
     * Used for batch mode operations
726
     */
727
    queueOperationAsync(
11✔
728
        category: OperationCategory,
40,744✔
729
        execute: (context: OperationContext) => Promise<void> | void,
40,744✔
730
        options?: Partial<OperationMetadata>,
40,744✔
731
    ): Promise<void> {
40,744✔
732
        const id = this.queueOperation(category, execute, options);
40,744✔
733

734
        // Create promise that resolves when this specific operation completes
735
        const promise = new Promise<void>((resolve, reject) => {
40,744✔
736
            // Store resolvers with the operation
737
            const operation = this.pendingOperations.get(id);
40,744✔
738
            if (operation) {
40,744✔
739
                operation.resolve = resolve;
40,744✔
740
                operation.reject = reject;
40,744✔
741
            }
40,744✔
742
        });
40,744✔
743

744
        if (this.batchMode) {
40,744✔
745
            // In batch mode, track the promise for later
746
            this.batchPromises.set(id, promise);
177✔
747
            // Return immediately resolved promise to avoid deadlock
748
            // The actual operation will execute when exitBatchMode is called
749
            return Promise.resolve();
177✔
750
        }
177✔
751

752
        // Not in batch mode, execute normally
753
        this.executeBatch();
40,567✔
754
        return promise;
40,567✔
755
    }
40,744✔
756

757
    /**
758
     * Wait for a specific operation to complete
759
     */
760
    private async waitForOperation(id: string): Promise<void> {
11✔
761
        while (
×
762
            this.pendingOperations.has(id) ||
×
763
            this.queuedOperations.has(id) ||
×
764
            this.runningOperations.has(id)
×
765
        ) {
×
766
            await new Promise((resolve) => setTimeout(resolve, OPERATION_POLL_INTERVAL_MS));
×
767
        }
×
768
    }
×
769

770
    /**
771
     * Get the AbortController for a specific operation
772
     */
773
    getOperationController(operationId: string): AbortController | undefined {
11✔
774
        return this.activeControllers.get(operationId);
×
775
    }
×
776

777
    /**
778
     * Cancel a specific operation
779
     */
780
    cancelOperation(operationId: string): boolean {
11✔
781
        const controller = this.activeControllers.get(operationId);
×
782
        if (controller && !controller.signal.aborted) {
×
783
            controller.abort();
×
784

785
            // Emit cancellation event
786
            this.eventManager.emitGraphEvent("operation-cancelled", {
×
787
                id: operationId,
×
788
                reason: "Manual cancellation",
×
789
            });
×
790

791
            return true;
×
792
        }
×
793

794
        return false;
×
795
    }
×
796

797
    /**
798
     * Mark a category as completed (for satisfying cross-batch dependencies)
799
     * This is useful when a category's requirements are met through other means
800
     * (e.g., style-init is satisfied by constructor initialization)
801
     */
802
    markCategoryCompleted(category: OperationCategory): void {
11✔
803
        this.completedCategories.add(category);
562✔
804
    }
562✔
805

806
    /**
807
     * Clear completed status for a category
808
     * This is useful when a category needs to be re-executed
809
     * (e.g., setStyleTemplate is called explicitly, overriding initial styles)
810
     */
811
    clearCategoryCompleted(category: OperationCategory): void {
11✔
812
        this.completedCategories.delete(category);
388✔
813
    }
388✔
814

815
    /**
816
     * Cancel all operations of a specific category
817
     */
818
    cancelByCategory(category: OperationCategory): number {
11✔
819
        let cancelledCount = 0;
×
820

821
        // Cancel pending operations
822
        this.pendingOperations.forEach((operation) => {
×
823
            if (operation.category === category) {
×
824
                if (this.cancelOperation(operation.id)) {
×
825
                    cancelledCount++;
×
826
                }
×
827
            }
×
828
        });
×
829

830
        return cancelledCount;
×
831
    }
×
832

833
    /**
834
     * Get current progress for an operation
835
     */
836
    getOperationProgress(operationId: string): OperationProgress | undefined {
11✔
837
        return this.operationProgress.get(operationId);
×
838
    }
×
839

840
    /**
841
     * Get all active operation IDs
842
     */
843
    getActiveOperations(): string[] {
11✔
844
        return Array.from(this.activeControllers.keys());
×
845
    }
×
846

847
    /**
848
     * Register a custom trigger for a specific operation category
849
     */
850
    registerTrigger(
11✔
851
        category: OperationCategory,
630✔
852
        trigger: (metadata?: OperationMetadata) => {
630✔
853
            category: OperationCategory;
854
            execute: (context: OperationContext) => Promise<void> | void;
855
            description?: string;
856
        } | null,
857
    ): void {
630✔
858
        if (!this.triggers.has(category)) {
630✔
859
            this.triggers.set(category, []);
629✔
860
        }
629✔
861

862
        const triggerArray = this.triggers.get(category);
630✔
863
        if (triggerArray) {
630✔
864
            triggerArray.push(trigger);
630✔
865
        }
630✔
866
    }
630✔
867

868
    /**
869
     * Trigger post-execution operations based on the completed operation
870
     */
871
    private triggerPostExecutionOperations(operation: Operation): void {
11✔
872
        // Check for default triggers
873
        const defaultTriggers = OperationQueueManager.POST_EXECUTION_TRIGGERS[operation.category];
13,918✔
874

875
        // Check for custom triggers
876
        const customTriggers = this.triggers.get(operation.category) ?? [];
13,918✔
877

878
        // Process default triggers
879
        if (defaultTriggers) {
13,918✔
880
            for (const triggerCategory of defaultTriggers) {
12,413✔
881
                // Check prerequisites
882
                if (triggerCategory === "layout-update" && this.hasLayoutEngine && !this.hasLayoutEngine()) {
12,413✔
883
                    continue; // Skip if no layout engine
2✔
884
                }
2✔
885

886
                // Queue the triggered operation
887
                void this.queueTriggeredOperation(triggerCategory, operation.metadata);
12,411✔
888
            }
12,411✔
889
        }
12,413✔
890

891
        // Process custom triggers
892
        for (const trigger of customTriggers) {
13,918✔
893
            const result = trigger(operation.metadata);
12,274✔
894
            if (result) {
12,274✔
895
                // Queue the custom triggered operation
896
                void this.queueTriggeredOperation(
12,272✔
897
                    result.category,
12,272✔
898
                    operation.metadata,
12,272✔
899
                    result.execute,
12,272✔
900
                    result.description,
12,272✔
901
                );
12,272✔
902
            }
12,272✔
903
        }
12,274✔
904
    }
13,918✔
905

906
    /**
907
     * Queue a triggered operation
908
     */
909
    private async queueTriggeredOperation(
11✔
910
        category: OperationCategory,
24,683✔
911
        sourceMetadata?: OperationMetadata,
24,683✔
912
        execute?: (context: OperationContext) => Promise<void> | void,
24,683✔
913
        description?: string,
24,683✔
914
    ): Promise<void> {
24,683✔
915
        // Notify test callback if set
916
        if (this.onOperationQueued) {
24,683✔
917
            this.onOperationQueued(category, description);
4✔
918
        }
4✔
919

920
        // Default execute function for layout-update
921
        if (!execute && category === "layout-update") {
24,683✔
922
            execute = (context: OperationContext) => {
12,343✔
923
                // This will be implemented by the Graph/LayoutManager
924
                context.progress.setMessage("Updating layout positions");
47✔
925
            };
47✔
926
        }
12,343✔
927

928
        if (!execute) {
24,683✔
929
            return; // No execute function provided
68✔
930
        }
68✔
931

932
        // Queue the operation
933
        await this.queueOperationAsync(
24,615✔
934
            category,
24,615✔
935
            execute,
24,615✔
936
            {
24,615✔
937
                description: description ?? `Triggered ${category}`,
24,679✔
938
                source: "trigger",
24,683✔
939
                skipTriggers: true, // Prevent trigger loops
24,683✔
940
            },
24,683✔
941
        );
24,683✔
942
    }
24,683✔
943
}
11✔
944

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