Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ This pattern is particularly useful when you need to handle initialization error
- `defaultPriority` (number, default: 0) - Default task priority
- `maxQueue` (number, default: Infinity) - Maximum queued tasks. Use `-1` for unlimited queue capacity (equivalent to `Infinity`)
- `delays` (object, default: {}) - **DEPRECATED:** Priority-to-delay mapping for completion delays (use `priorities` instead)
- `priorities` (object, default: {}) - Priority-specific configuration: `{ [priority]: { concurrency, postDelay, startDelay } }`
- `priorities` (object, default: {}) - Priority-specific configuration: `{ [priority]: { concurrency, postDelay, startDelay } }`. Keys must be integers; any other key emits a `warning` event with `type: "invalid-priority"` (and is ignored when it isn't numeric at all)
- `concurrency` (number) - Maximum concurrent tasks for this priority (defaults to global concurrency limit)
- `postDelay` (number) - Delay after task completion before next task of same priority
- `startDelay` (number) - Delay before task execution (pre-execution delay)
Expand Down
47 changes: 42 additions & 5 deletions src/hold-my-task.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export class HoldMyTask extends EventEmitter {
* @param {boolean} [options.smartScheduling=true] - Use dynamic timeouts instead of constant polling for better performance
* @param {number} [options.tick=25] - Polling interval in milliseconds when smartScheduling is disabled
* @param {number} [options.healingInterval=5000] - Self-healing check interval in milliseconds (smart scheduling only)
* @param {Object} [options.priorities={}] - Priority-specific default configurations
* @param {Object} [options.priorities={}] - Priority-specific default configurations, keyed by integer priority. A non-integer key emits an "invalid-priority" warning event
* @param {number} [options.priorities[priority].concurrency] - Maximum concurrent tasks for this priority (defaults to global concurrency limit)
* @param {number} [options.priorities[priority].postDelay] - Delay after task completion before next task of same priority
* @param {number} [options.priorities[priority].startDelay] - Delay before task execution (pre-execution delay)
Expand Down Expand Up @@ -210,6 +210,41 @@ export class HoldMyTask extends EventEmitter {
}
}

/**
* Resolves a `priorities` / `delays` object key to a numeric priority.
* Integer keys ("0", "10", "-1") resolve silently. Any other key emits a `warning` event of
* type "invalid-priority" so a typo can't silently disable a priority config: a key
* parseInt can't read (e.g. "high") is ignored, and a key it truncates (e.g. "2.5") keeps
* its historical meaning - the truncated priority - but is reported.
* @param {string} key - The object key as written in the config
* @param {string} option - The option the key came from ("priorities" or "delays")
* @returns {number|null} The numeric priority, or null when the key is ignored
* @private
* @internal
*/
_resolvePriorityKey(key, option) {
if (/^-?\d+$/.test(key)) {
return Number(key);
}

const parsed = parseInt(key);
const priority = isNaN(parsed) ? null : parsed;
const message =
priority === null
? `Priority key '${key}' in '${option}' is not an integer and was ignored. Priority keys must be integers.`
: `Priority key '${key}' in '${option}' is not an integer; it was applied to priority ${priority}. Priority keys must be integers.`;
setImmediate(() =>
this.emit("warning", {
type: "invalid-priority",
message,
option,
key,
priority
})
);
return priority;
}

/**
* Synchronous initialization for backwards compatibility
* @private
Expand Down Expand Up @@ -304,8 +339,9 @@ export class HoldMyTask extends EventEmitter {
// First, transform any existing priority configurations to use new property names
if (cleanOptions.priorities && typeof cleanOptions.priorities === "object") {
for (const [priority, config] of Object.entries(cleanOptions.priorities)) {
const priorityNum = parseInt(priority);
if (!isNaN(priorityNum) && config != null) {
if (config == null) continue;
const priorityNum = this._resolvePriorityKey(priority, "priorities");
if (priorityNum !== null) {
transformedPriorities[priorityNum] = HoldMyTask._transformDelayProperties(config, this);
}
}
Expand All @@ -325,8 +361,9 @@ export class HoldMyTask extends EventEmitter {
);

for (const [priority, delay] of Object.entries(cleanOptions.delays)) {
const priorityNum = parseInt(priority);
if (!isNaN(priorityNum) && delay != null) {
if (delay == null) continue;
const priorityNum = this._resolvePriorityKey(priority, "delays");
if (priorityNum !== null) {
transformedPriorities[priorityNum] = {
postDelay: delay, // Use new property name
startDelay: 0, // Use new property name
Expand Down
73 changes: 73 additions & 0 deletions tests/PriorityKeyValidation.test.vitest.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
*
* @Project: @cldmv/holdmytask
* @Filename: /tests/PriorityKeyValidation.test.vitest.mjs
* @Date: 2026-10-03T17:24:45-07:00 (1791073485)
* @Author: Nate Corcoran <CLDMV>
* @Email: <Shinrai@users.noreply.github.com>
* -----
* @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
* @Last modified time: 2026-10-03T17:25:44-07:00 (1791073544)
* -----
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
*
*/

import { test, expect, describe } from "vitest";
import { HoldMyTask } from "../src/hold-my-task.mjs";

// #54: priority keys that aren't integers used to be dropped (or truncated) silently.
const collectWarnings = async (options) => {
const queue = new HoldMyTask({ autoStart: false, ...options });
const warnings = [];
queue.on("warning", (w) => warnings.push(w));
await new Promise((resolve) => setImmediate(resolve));
return { queue, warnings: warnings.filter((w) => w.type === "invalid-priority") };
};

describe("priority key validation", () => {
test("a non-numeric priorities key emits an invalid-priority warning and is ignored", async () => {
const { queue, warnings } = await collectWarnings({ priorities: { high: { postDelay: 100 }, 1: { postDelay: 50 } } });
expect(warnings).toEqual([
{
type: "invalid-priority",
message: expect.stringContaining("'high'"),
option: "priorities",
key: "high",
priority: null
}
]);
expect(queue.getPriorityConfigurations()).toEqual({ 1: expect.objectContaining({ postDelay: 50 }) });
queue.destroy();
});

test("a non-integer key that parseInt truncates warns and keeps the truncated priority", async () => {
const { queue, warnings } = await collectWarnings({ priorities: { "2.5": { postDelay: 25 } } });
expect(warnings).toEqual([expect.objectContaining({ type: "invalid-priority", option: "priorities", key: "2.5", priority: 2 })]);
expect(warnings[0].message).toContain("priority 2");
expect(queue.getPriorityConfig(2).postDelay).toBe(25);
queue.destroy();
});

test("the deprecated delays option is validated the same way", async () => {
const { queue, warnings } = await collectWarnings({ delays: { low: 10, 3: 30 } });
expect(warnings).toEqual([expect.objectContaining({ type: "invalid-priority", option: "delays", key: "low", priority: null })]);
expect(queue.getPriorityConfig(3).postDelay).toBe(30);
queue.destroy();
});

test("integer keys, including negative ones, produce no invalid-priority warning", async () => {
const { queue, warnings } = await collectWarnings({
priorities: { 0: { postDelay: 1 }, 10: { postDelay: 2 }, "-1": { postDelay: 3 } }
});
expect(warnings).toEqual([]);
expect(queue.getPriorityConfig(-1).postDelay).toBe(3);
queue.destroy();
});

test("a key with a null config is skipped without a warning", async () => {
const { queue, warnings } = await collectWarnings({ priorities: { 1: null } });
expect(warnings).toEqual([]);
queue.destroy();
});
});
2 changes: 1 addition & 1 deletion types/src/hold-my-task.d.mts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading