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

graphty-org / graphty-element / 19792929756

30 Nov 2025 02:57AM UTC coverage: 86.308% (+3.9%) from 82.377%
19792929756

push

github

apowers313
docs: fix stories for chromatic

3676 of 4303 branches covered (85.43%)

Branch coverage included in aggregate %.

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

1093 existing lines in 30 files now uncovered.

17371 of 20083 relevant lines covered (86.5%)

7075.46 hits per line

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

90.69
/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,
646✔
129
        options: {
646✔
130
            concurrency?: number;
131
            autoStart?: boolean;
132
            intervalCap?: number;
133
            interval?: number;
134
        } = {},
646✔
135
    ) {
646✔
136
        const queueOptions: {
646✔
137
            concurrency?: number;
138
            autoStart?: boolean;
139
            intervalCap?: number;
140
            interval?: number;
141
        } = {
646✔
142
            concurrency: options.concurrency ?? 1, // Sequential by default
646✔
143
            autoStart: options.autoStart ?? true,
646✔
144
        };
646✔
145

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

295
            let shouldCancel = false;
14,273,168✔
296

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

302
            // Check custom shouldObsolete function
303
            if (!shouldCancel && shouldObsolete) {
14,313,356✔
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,951✔
312
                // Check progress if respectProgress is enabled
313
                if (respectProgress && this.runningOperations.has(operation.id)) {
24,504✔
314
                    const progress = this.operationProgress.get(operation.id);
6✔
315
                    if (progress && progress.percent >= PROGRESS_CANCELLATION_THRESHOLD) {
6✔
316
                        // Don't cancel near-complete operations
317
                        continue;
1✔
318
                    }
1✔
319
                }
6✔
320

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

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

334
                    // Remove from pending/queued
335
                    this.pendingOperations.delete(operation.id);
24,396✔
336
                    this.queuedOperations.delete(operation.id);
24,396✔
337
                    this.currentBatch.delete(operation.id);
24,396✔
338
                }
24,396✔
339
            }
24,504✔
340
        }
14,313,356✔
341
    }
40,342✔
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);
80,595✔
349
        this.currentBatch.clear();
80,595✔
350

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

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

362
            this.pendingOperations.delete(id);
40,505✔
363
        });
80,595✔
364

365
        if (operations.length === 0) {
80,595✔
366
            return;
40,274✔
367
        }
40,274✔
368

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

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

406
        // Emit batch complete event after all operations
407
        this.eventManager.emitGraphEvent("operation-batch-complete", {
40,321✔
408
            operationCount: sortedOperations.length,
40,321✔
409
            operations: sortedOperations.map((op) => ({
40,321✔
410
                id: op.id,
40,505✔
411
                category: op.category,
40,505✔
412
                description: op.metadata?.description,
40,505✔
413
            })),
40,321✔
414
        });
40,321✔
415
    }
80,595✔
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,321✔
423
        operations.forEach((op) => {
40,321✔
424
            const ops = operationsByCategory.get(op.category) ?? [];
40,505✔
425
            ops.push(op);
40,505✔
426
            operationsByCategory.set(op.category, ops);
40,505✔
427
        });
40,321✔
428

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

432
        // Build dependency edges for toposort
433
        const edges: [OperationCategory, OperationCategory][] = [];
40,321✔
434
        OperationQueueManager.CATEGORY_DEPENDENCIES.forEach(([dependent, dependency]) => {
40,321✔
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)) {
443,531✔
440
                if (categories.includes(dependency)) {
640✔
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
            }
640✔
449
        });
40,321✔
450

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

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

473
        return sortedOperations;
40,321✔
474
    }
40,321✔
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,000✔
482
        this.runningOperations.set(operation.id, operation);
14,000✔
483

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

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

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

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

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

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

508
            // Resolve the operation's promise
509
            if (operation.resolve) {
13,983✔
510
                operation.resolve();
13,895✔
511
            }
13,895✔
512

513
            // Trigger post-execution operations if not skipped
514
            if (!operation.metadata?.skipTriggers) {
14,000✔
515
                this.triggerPostExecutionOperations(operation);
13,679✔
516
            }
13,679✔
517
        } catch (error) {
14,000✔
518
            if (error && (error as Error).name === "AbortError") {
23✔
519
                // Reject the operation's promise
520
                if (operation.reject) {
3!
UNCOV
521
                    operation.reject(error);
×
UNCOV
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,000✔
536
            // Always remove from running operations
537
            this.runningOperations.delete(operation.id);
14,000✔
538
        }
14,000✔
539
    }
14,000✔
540

541
    /**
542
     * Create progress context for an operation
543
     */
544
    private createProgressContext(id: string, category: OperationCategory): ProgressContext {
11✔
545
        return {
14,000✔
546
            setProgress: (percent: number) => {
14,000✔
547
                const progress = this.operationProgress.get(id);
14,036✔
548
                if (progress) {
14,036✔
549
                    progress.percent = percent;
14,008✔
550
                    progress.lastUpdate = Date.now();
14,008✔
551
                    this.emitProgressUpdate(id, category, progress);
14,008✔
552
                }
14,008✔
553
            },
14,036✔
554
            setMessage: (message: string) => {
14,000✔
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,000✔
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,000✔
571
    }
14,000✔
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,085✔
578
            id,
14,085✔
579
            category,
14,085✔
580
            progress: progress.percent,
14,085✔
581
            message: progress.message,
14,085✔
582
            phase: progress.phase,
14,085✔
583
            duration: Date.now() - progress.startTime,
14,085✔
584
        });
14,085✔
585
    }
14,085✔
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) {
160✔
609
            this.executeBatch();
32✔
610
        }
32✔
611

612
        // Then wait for queue to be idle
613
        await this.queue.onIdle();
160✔
614
    }
160✔
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✔
UNCOV
668
        this.batchingEnabled = false;
×
UNCOV
669
    }
×
670

671
    enableBatching(): void {
11✔
UNCOV
672
        this.batchingEnabled = true;
×
673
        // TODO: Operations queued while batching was disabled will be executed
674
        // when waitForCompletion() is called
UNCOV
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,407✔
729
        execute: (context: OperationContext) => Promise<void> | void,
40,407✔
730
        options?: Partial<OperationMetadata>,
40,407✔
731
    ): Promise<void> {
40,407✔
732
        const id = this.queueOperation(category, execute, options);
40,407✔
733

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

744
        if (this.batchMode) {
40,407✔
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,230✔
754
        return promise;
40,230✔
755
    }
40,407✔
756

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

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

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

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

UNCOV
791
            return true;
×
UNCOV
792
        }
×
793

UNCOV
794
        return false;
×
UNCOV
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);
509✔
804
    }
509✔
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);
326✔
813
    }
326✔
814

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

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

UNCOV
830
        return cancelledCount;
×
UNCOV
831
    }
×
832

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

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

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

862
        const triggerArray = this.triggers.get(category);
577✔
863
        if (triggerArray) {
577✔
864
            triggerArray.push(trigger);
577✔
865
        }
577✔
866
    }
577✔
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,679✔
874

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

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

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

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

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

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

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

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

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

© 2026 Coveralls, Inc