diff --git a/README.md b/README.md index 6797636..529dddc 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,40 @@ # @cldmv/holdmytask -[![npm version](https://img.shields.io/npm/v/%40cldmv%2Fholdmytask.svg?style=for-the-badge&logo=npm&logoColor=white&labelColor=CB3837)](https://www.npmjs.com/package/@cldmv/holdmytask) -[![npm downloads](https://img.shields.io/npm/dm/%40cldmv%2Fholdmytask.svg?style=for-the-badge&logo=npm&logoColor=white&labelColor=CB3837)](https://www.npmjs.com/package/@cldmv/holdmytask) -[![GitHub downloads](https://img.shields.io/github/downloads/CLDMV/holdmytask/total?style=for-the-badge&logo=github&logoColor=white&labelColor=181717)](https://github.com/CLDMV/holdmytask/releases) -[![Last commit](https://img.shields.io/github/last-commit/CLDMV/holdmytask?style=for-the-badge&logo=github&logoColor=white&labelColor=181717)](https://github.com/CLDMV/holdmytask/commits) -[![npm last update](https://img.shields.io/npm/last-update/%40cldmv%2Fholdmytask?style=for-the-badge&logo=npm&logoColor=white&labelColor=CB3837)](https://www.npmjs.com/package/@cldmv/holdmytask) +**@cldmv/holdmytask** is a tiny, dependency-free task queue for Node.js that executes tasks with priority ordering, concurrency control, and completion delays. It is built for asynchronous workflows with real timing requirements: rate-limited APIs, device control, UI refreshes, and anything else where _when_ a task runs matters as much as _whether_ it runs. -[![Contributors](https://img.shields.io/github/contributors/CLDMV/holdmytask.svg?style=for-the-badge&logo=github&logoColor=white&labelColor=181717)](https://github.com/CLDMV/holdmytask/graphs/contributors) [![Sponsor shinrai](https://img.shields.io/github/sponsors/shinrai?style=for-the-badge&logo=githubsponsors&logoColor=white&labelColor=EA4AAA&label=Sponsor)](https://github.com/sponsors/shinrai) +Every task can carry a priority, a scheduled start time, a timeout, an `AbortSignal`, and metadata. Per-priority concurrency limits and pre/post delays shape throughput, urgent work can bypass an active delay, and tasks that share a coalescing key collapse into a single execution whose result resolves every caller. Smart scheduling sets precise timers for the next ready task instead of polling. -A tiny, dependency-free task queue for Node.js that executes tasks with priority ordering, concurrency control, and completion delays. Perfect for managing asynchronous workflows with sophisticated timing requirements. +Use it with callbacks or promises, from ESM or CommonJS, with full TypeScript definitions included. + +> _A tiny task queue that waits until your task is ready._ + +[![npm version]][npm_version_url] [![npm downloads]][npm_downloads_url] [![GitHub downloads]][github_downloads_url] [![Last commit]][last_commit_url] [![npm last update]][npm_last_update_url] [![coverage]][coverage_url] + +[![Contributors]][contributors_url] [![Sponsor shinrai]][sponsor_url] + +--- ## ✨ What's New -### Latest: v2.0.4 (October 2026) +### Latest: v2.0.5 (October 2026) -- **Clearer `require()` failure on older Node.js** — The CommonJS entry now throws an `ERR_REQUIRE_ESM` error that names the supported Node.js versions (`^20.19.0` or `>=22.12.0`) and points to `import()`, instead of a bare error on versions without `require(esm)`. Behavior on supported versions is unchanged. -- **Security patch for development tooling** — Updates `brace-expansion` to 5.0.12, fixing [GHSA-q2hr-2g5m-vwhr](https://github.com/advisories/GHSA-q2hr-2g5m-vwhr), a quadratic-time denial of service in a development-only dependency pulled in by `minimatch`. `@eslint/css` moves to 2.0.0 and `prettier` to 3.9.9. -- [View full v2.0.4 Changelog](https://github.com/CLDMV/holdmytask/blob/master/docs/changelog/v2/v2.0.4.md) +- **Task-level `postDelay` / `startDelay` work as documented** — `enqueue()` now honors the documented `postDelay` and `startDelay` task options, and a task-level completion delay is enforced even when the task's priority has no `postDelay` of its own. The old `delay` / `start` names still work as deprecated aliases and emit one `deprecation` warning per queue instance. +- **Safer failure reporting** — A failing callback-style task no longer crashes the process when no `error` listener is attached; the failure always reaches the callback, and the `error` event is emitted only when someone listens. Non-integer `priorities` keys now produce an `invalid-priority` warning instead of being dropped silently. The README was rewritten to the CLDMV layout with every example on the current API. +- **Header tooling on fix-headers 2.2.0** — the `@cldmv/fix-headers` and `@cldmv/configs` dev dependencies move to 2.2.0 and 1.2.4, so `@Last modified by` now follows content edits only; no file was restamped, and the published package is unaffected (#61, #62). +- [View full v2.0.5 Changelog](https://github.com/CLDMV/holdmytask/blob/master/docs/changelog/v2/v2.0.5.md) ### Recent Releases +- **v2.0.4** (October 2026) — Clearer `require()` error on Node.js without `require(esm)`; `brace-expansion` security patch in development tooling ([Changelog](https://github.com/CLDMV/holdmytask/blob/master/docs/changelog/v2/v2.0.4.md)) - **v2.0.3** (October 2026) — Development toolchain moves to vitest 5; bundle-size and v4 workflow updates; verbatim Apache-2.0 license text ([Changelog](https://github.com/CLDMV/holdmytask/blob/master/docs/changelog/v2/v2.0.3.md)) - **v2.0.2** (September 2026) — Development dependency update (`@humanfs/node`); no other changes ([Changelog](https://github.com/CLDMV/holdmytask/blob/master/docs/changelog/v2/v2.0.2.md)) - **v2.0.1** (September 2026) — Fix: a task's timeout timer is now cleared when the task fails or is cancelled ([Changelog](https://github.com/CLDMV/holdmytask/blob/master/docs/changelog/v2/v2.0.1.md)) -- **v2.0.0** (August 2026) — `HoldMyTask` and its aliases are the real class again (breaking for code adapted to v1.6.1's factories); scheduler deadlock fix; `holdmytask-dev` export condition ([Changelog](https://github.com/CLDMV/holdmytask/blob/master/docs/changelog/v2/v2.0.0.md)) -## ✨ Features +📚 **For complete version history and detailed release notes, see the [docs/changelog/](https://github.com/CLDMV/holdmytask/tree/master/docs/changelog/) folder.** + +--- + +## 🚀 Key Features - **Smart scheduling** - Dynamic timeout-based scheduling for optimal performance - **Task coalescing** - Intelligent merging of similar tasks for efficiency @@ -40,13 +49,72 @@ A tiny, dependency-free task queue for Node.js that executes tasks with priority - **TypeScript ready** - Full type definitions included - **Zero dependencies** - Lightweight and fast +--- + ## 📦 Installation +### Requirements + +- **Node.js v18.12.0 or higher** for ESM (`import`), as declared in the package's `engines` field +- **CommonJS `require()`** needs Node.js `^20.19.0` or `>=22.12.0`. The CommonJS entry is a thin wrapper that loads the ESM build through Node's synchronous `require(esm)`. On older Node.js versions `require("@cldmv/holdmytask")` throws an `ERR_REQUIRE_ESM` error that names the supported versions; load the package with `import()` instead. + +### Install + ```bash npm install @cldmv/holdmytask ``` -## � Import Options +--- + +## 🚀 Quick Start + +```javascript +import { HoldMyTask } from "@cldmv/holdmytask"; + +const queue = new HoldMyTask({ + concurrency: 2, + priorities: { + 1: { postDelay: 100 }, // 100ms delay after priority 1 tasks + 2: { postDelay: 200 } // 200ms delay after priority 2 tasks + }, + coalescing: { + defaults: { + windowDuration: 200, // Group similar tasks within 200ms + maxDelay: 1000 // Force execution after 1000ms max + } + } +}); + +// Enqueue a task +queue.enqueue( + async (signal) => { + // Your task logic here + return "task result"; + }, + (error, result) => { + if (error) { + console.error("Task failed:", error); + } else { + console.log("Task completed:", result); + } + }, + { priority: 1, timeout: 5000 } +); + +// Coalescing example - multiple similar tasks become one +const results = await Promise.all([ + queue.enqueue(async () => updateUI(), { coalescingKey: "ui.update" }), + queue.enqueue(async () => updateUI(), { coalescingKey: "ui.update" }), + queue.enqueue(async () => updateUI(), { coalescingKey: "ui.update" }) +]); +// Only one updateUI() call executes, all three promises resolve with the same result +``` + +CommonJS works the same way: `const { HoldMyTask } = require("@cldmv/holdmytask")` (see [Requirements](#requirements) for the Node.js versions that support `require()`). + +--- + +## 📥 Import Options The library provides flexible import options for different use cases: @@ -62,13 +130,10 @@ import HoldMyTask from "@cldmv/holdmytask"; // Import everything import * as HoldMyTaskLib from "@cldmv/holdmytask"; // Uses optimized distribution files -``` - -### Development Source Access -```javascript -import { HoldMyTask } from "@cldmv/holdmytask/main"; -// Conditional: uses source files in development, dist files in production +// CommonJS (Node.js ^20.19.0 or >=22.12.0) +const HoldMyTask = require("@cldmv/holdmytask"); // the class itself +const { HoldMyTask: Named } = require("@cldmv/holdmytask"); // or by name ``` ### Common Queue System Aliases @@ -89,54 +154,27 @@ import { // All aliases are functionally identical const myQueue = new queue({ concurrency: 5 }); const stdQueue = new Queue({ concurrency: 5 }); -const manager = new TaskManager({ priorities: { high: { delay: 100 } } }); +const manager = new TaskManager({ priorities: { 1: { postDelay: 100 } } }); ``` -### Direct Source Import (Development Only) +The ESM entry also exports async factory helpers that resolve to a new instance: `createHoldMyTask(options)`, `createQueue(options)`, `createTaskManager(options)`, and `createTaskProcessor(options)`. + +### Development Source Access ```javascript -import { HoldMyTask } from "@cldmv/holdmytask/src"; -// Always uses source files - bypasses devcheck +import { HoldMyTask } from "@cldmv/holdmytask/main"; +// Resolves to dist files by default; resolves to src/ when Node.js runs with the holdmytask-dev condition ``` -**Note:** The main import automatically runs environment checks in development mode to ensure proper configuration. +To run against the unbuilt source (from a checkout of this repository), start Node.js with the `holdmytask-dev` export condition: -## 🚀 Quick Start - -```javascript -import { HoldMyTask } from "@cldmv/holdmytask"; - -const queue = new HoldMyTask({ - concurrency: 2, - delays: { 1: 100, 2: 200 }, // 100ms delay after priority 1 tasks, 200ms after priority 2 - coalescingWindowDuration: 200, // Group similar tasks within 200ms - coalescingMaxDelay: 1000 // Force execution after 1000ms max -}); +```bash +node --conditions=holdmytask-dev your-script.mjs +``` -// Enqueue a task -queue.enqueue( - async (signal) => { - // Your task logic here - return "task result"; - }, - (error, result) => { - if (error) { - console.error("Task failed:", error); - } else { - console.log("Task completed:", result); - } - }, - { priority: 1, timeout: 5000 } -); +**Note:** In a checkout of this repository, the root import runs a best-effort development environment check (`devcheck.mjs`) that exits with setup instructions when `src/` is present but the `holdmytask-dev` condition is not set. The check is skipped when the package is installed as a dependency and in CI. -// Coalescing example - multiple similar tasks become one -const results = await Promise.all([ - queue.enqueue(async () => updateUI(), { coalescingKey: "ui.update" }), - queue.enqueue(async () => updateUI(), { coalescingKey: "ui.update" }), - queue.enqueue(async () => updateUI(), { coalescingKey: "ui.update" }) -]); -// Only one updateUI() call executes, all three promises resolve with the same result -``` +--- ## 🔄 Promise API @@ -180,6 +218,8 @@ queue.enqueue(task1, callback, options); const result = await queue.enqueue(task2, options); ``` +--- + ## 🏗️ Async Constructor Pattern HoldMyTask works excellently with async constructor patterns for classes that need initialization: @@ -241,6 +281,8 @@ This pattern is particularly useful for: - **Resource acquisition**: Set up required resources in a controlled manner - **Dependency injection**: Initialize dependencies in the correct order +--- + ## 📚 API Reference ### Constructor @@ -251,25 +293,22 @@ const queue = new HoldMyTask(options?) ### Async Initialization Pattern -For async initialization that allows event listeners to be attached before any initialization events can fire, use `sync: false`: +With `sync: false`, the constructor returns a Promise that resolves to the instance once initialization has run on the next turn of the event loop. The returned value is a plain Promise, so attach listeners to the resolved instance: ```javascript // Create instance with async initialization -const queuePromise = new HoldMyTask({ +const queue = await new HoldMyTask({ concurrency: 5, maxQueue: 100, sync: false // Enable async initialization }); -// Attach event listeners before initialization completes -queuePromise.on("error", (err) => console.error("Queue error:", err)); -queuePromise.on("warning", (warning) => console.warn("Warning:", warning.message)); - -// Wait for initialization to complete -const queue = await queuePromise; +// Initialization warnings are emitted asynchronously, so listeners attached here still receive them +queue.on("error", (err) => console.error("Queue error:", err)); +queue.on("warning", (warning) => console.warn("Warning:", warning.message)); ``` -This pattern is particularly useful when you need to handle initialization errors or warnings through event listeners rather than try/catch blocks. +Initialization warnings (such as [deprecation warnings](#warning-events)) are emitted on `setImmediate` in both modes, so a listener attached right after a synchronous `new HoldMyTask(...)` receives them as well. **Options:** @@ -279,26 +318,25 @@ This pattern is particularly useful when you need to handle initialization error - `autoStart` (boolean, default: true) - Whether to start processing immediately - `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 + - `postDelay` (number) - Delay after a task of this priority completes before the next task can start (the delay applies queue-wide; see [Priority Delays](#priority-delays---advanced-timing-control)) - `startDelay` (number) - Delay before task execution (pre-execution delay) - `coalescing` (object) - Enhanced coalescing configuration - `defaults` (object) - Default settings for all coalescing keys - `windowDuration` (number, default: 200) - Window duration in milliseconds - `maxDelay` (number, default: 1000) - Maximum delay before forcing execution - - `delay` (number) - Default completion delay for coalescing tasks - - `start` (number) - Default start delay for coalescing tasks - - `resolveAllPromises` (boolean, default: true) - Whether all promises resolve with result + - `postDelay` (number) - Default completion delay for coalescing tasks + - `startDelay` (number) - Default start delay for coalescing tasks + - `resolveAllPromises` (boolean, default: true) - Whether every task in a coalescing group resolves with the result; when `false`, only the newest task resolves and the others are rejected with `Task was coalesced with a newer task` - `multipleCallbacks` (boolean, default: false) - Whether to call multiple callbacks - - `keys` (object) - Per-key configuration overrides: `{ [key]: { windowDuration, maxDelay, delay, start, ... } }` -- `coalescingWindowDuration` (number, default: 200) - **DEPRECATED:** Use `coalescing.defaults.windowDuration` -- `coalescingMaxDelay` (number, default: 1000) - **DEPRECATED:** Use `coalescing.defaults.maxDelay` -- `coalescingResolveAllPromises` (boolean, default: true) - **DEPRECATED:** Use `coalescing.defaults.resolveAllPromises` -- `onError` (function) - Global error handler + - `keys` (object) - Per-key configuration overrides: `{ [key]: { windowDuration, maxDelay, postDelay, startDelay, ... } }` +- `healingInterval` (number, default: 5000) - Self-healing check interval in milliseconds (smart scheduling only) +- `sync` (boolean, default: true) - Set to `false` for [async initialization](#async-initialization-pattern); the constructor then returns a Promise that resolves to the instance - `now` (function) - Injectable clock for testing +Deprecated option names are still accepted and converted for you, but each one emits a `warning` event; see [Deprecated Options](#deprecated-options) for the mapping. + ### Methods #### `enqueue(task, callback?, options?)` @@ -314,12 +352,12 @@ Adds a task to the queue. **Task Options:** - `priority` (number) - Task priority (higher = more important) -- `delay` (number) - Override completion delay for this task (use -1 to bypass delays) +- `postDelay` (number) - Override completion delay for this task (use -1 to bypass delays). `delay` is a deprecated alias that emits a `warning` event once per queue - `bypassDelay` (boolean) - If true, skip any active delay period and start immediately - `timeout` (number) - Timeout in milliseconds - `signal` (AbortSignal) - External abort signal - `timestamp` (number) - Absolute execution timestamp -- `start` (number) - Milliseconds from now when the task should be ready to run (convenience for timestamp calculation) +- `startDelay` (number) - Milliseconds from now when the task should be ready to run (convenience for timestamp calculation). `start` is a deprecated alias that emits a `warning` event once per queue - `coalescingKey` (string) - Tasks with the same coalescing key can be merged for efficiency - `mustRunBy` (number) - Absolute timestamp by which the task must execute (overrides coalescing delays) - `metadata` (any) - Custom metadata attached to the task. Individual metadata is always directly accessible via the returned task handle @@ -439,10 +477,10 @@ queue.enqueue(task2, { id: "duplicate" }); // throws: Task ID "duplicate" alread - `configurePriority(priority, config)` - Configure or update priority-specific settings - `priority` (string|number) - Priority level to configure - - `config` (object) - Configuration: `{ delay?, maxDelay?, start? }` + - `config` (object) - Configuration: `{ postDelay?, startDelay? }` - `configureCoalescingKey(key, config)` - Configure or update coalescing key settings - `key` (string) - Coalescing key to configure - - `config` (object) - Configuration: `{ windowDuration?, maxDelay?, delay?, start?, multipleCallbacks?, resolveAllPromises? }` + - `config` (object) - Configuration: `{ windowDuration?, maxDelay?, postDelay?, startDelay?, multipleCallbacks?, resolveAllPromises? }` - `getPriorityConfig(priority, taskOptions?)` - Get effective configuration for a specific priority - `getPriorityConfigurations()` - Get all configured priorities and their settings - `getCoalescingConfig(coalescingKey, taskOptions?)` - Get effective configuration for a specific coalescing key @@ -575,7 +613,7 @@ const queue = new HoldMyTask({ concurrency: 2 }); // Add some tasks queue.enqueue(async () => "task1"); -queue.enqueue(async () => "task2", { priority: 2, start: 5000 }); +queue.enqueue(async () => "task2", { priority: 2, startDelay: 5000 }); // Check initial state console.log("Initial state:"); @@ -617,28 +655,66 @@ queue.on("warning", (warning) => { }); ``` -### Deprecation Warning Events +When a callback-style task fails, times out, or is aborted, the task's callback receives the failure. The queue also emits an `error` event with the task (its `error` and `status` properties describe the failure), but only when an `error` listener is attached, so a listener is optional. Promise-style tasks report task failures only through the rejected promise. + +The callback's first argument is an error payload rather than the raw error: `{ type: "timeout", message }`, `{ type: "canceled", message: "Task was aborted" }`, or `{ type: "error", error }`. + +### Warning Events + +HoldMyTask reports configuration problems through `warning` events instead of throwing. The events are emitted asynchronously (on `setImmediate`), so a listener attached right after the constructor returns still receives them. There are two types. -When deprecated configuration options are used, HoldMyTask emits `warning` events to help with migration: +**Deprecations** (`type: "deprecation"`): when deprecated options are used, HoldMyTask converts them to the current names and emits one warning per deprecated option: + +- `type` - `"deprecation"` +- `message` - a human-readable description +- `deprecated` - the deprecated option or property name +- `replacement` - the name to use instead + +**Invalid priority keys** (`type: "invalid-priority"`): `priorities` (and the deprecated `delays`) must be keyed by integers. A key that isn't one is reported instead of being dropped silently: a key that isn't numeric at all (`"high"`) is ignored, and a key with a fractional part (`"2.5"`) is applied to the truncated priority (`2`), as before: + +- `type` - `"invalid-priority"` +- `message` - a human-readable description +- `option` - the option the key came from (`"priorities"` or `"delays"`) +- `key` - the key as written +- `priority` - the priority it was applied to, or `null` when it was ignored ```javascript const queue = new HoldMyTask({ - delays: { 1: 100 }, // Deprecated option - coalescingWindowDuration: 200 // Deprecated option + delays: { 1: 100 }, // Deprecated: use priorities + coalescingWindowDuration: 200 // Deprecated: use coalescing.defaults.windowDuration }); queue.on("warning", (warning) => { - console.warn(`Deprecation Warning: ${warning.message}`); - console.warn(`Use: ${warning.replacement}`); - console.warn(`In: ${warning.source}`); + if (warning.type === "deprecation") { + console.warn(`${warning.message} (${warning.deprecated} -> ${warning.replacement})`); + } }); // Output: -// Deprecation Warning: Option 'delays' is deprecated -// Use: priorities: { 1: { delay: 100 } } -// In: constructor options +// Option 'coalescingWindowDuration' is deprecated. Use 'coalescing.defaults.windowDuration' instead. (coalescingWindowDuration -> coalescing.defaults.windowDuration) +// Option 'delays' is deprecated. Use 'priorities' instead with { [priority]: { postDelay: value, startDelay: 0 } } format. (delays -> priorities) ``` +### Deprecated Options + +These names still work, but each emits a deprecation warning. Use the current names in new code: + +| Deprecated | Use instead | +| ------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------- | +| `delays: { [priority]: ms }` | `priorities: { [priority]: { postDelay: ms } }` | +| `delay` in a `priorities` entry, `coalescing.defaults`, `coalescing.keys` entry, `configurePriority()` or `configureCoalescingKey()` | `postDelay` | +| `start` in the same places | `startDelay` | +| `delay` as a task option passed to `enqueue()` | `postDelay` | +| `start` as a task option passed to `enqueue()` | `startDelay` | +| `coalescingWindowDuration` (constructor) | `coalescing.defaults.windowDuration` | +| `coalescingMaxDelay` (constructor) | `coalescing.defaults.maxDelay` | +| `coalescingMultipleCallbacks` (constructor) | `coalescing.defaults.multipleCallbacks` | +| `coalescingResolveAllPromises` (constructor) | `coalescing.defaults.resolveAllPromises` | + +Each task-level alias (`delay`, `start`) warns once per queue instance, not once per task. If both a deprecated alias and its replacement are given, the replacement wins. The per-task `coalescingWindowDuration` / `coalescingMaxDelay` / `coalescingMultipleCallbacks` / `coalescingResolveAllPromises` overrides are not deprecated and remain the current names for task options. + +--- + ## ⚙️ Concurrency & Delays Interaction Understanding how concurrency and delays work together is crucial for optimal queue behavior: @@ -648,7 +724,7 @@ Understanding how concurrency and delays work together is crucial for optimal qu ```javascript const queue = new HoldMyTask({ concurrency: 3, // Up to 3 tasks can run simultaneously - delays: { 1: 500, 2: 1000 } + priorities: { 1: { postDelay: 500 }, 2: { postDelay: 1000 } } }); ``` @@ -661,7 +737,7 @@ const queue = new HoldMyTask({ ### Timing Examples ```javascript -// Timeline with concurrency: 2, delays: { 1: 1000 } +// Timeline with concurrency: 2, priorities: { 1: { postDelay: 1000 } } // 10:00:00 - Start: TaskA (pri 1), TaskB (pri 1) - both running // 10:00:02 - TaskA completes → 1000ms delay starts, TaskB still running @@ -709,10 +785,6 @@ HoldMyTask uses a sophisticated dual-heap scheduling system for optimal performa This architecture enables handling thousands of tasks with precise timing control while maintaining excellent performance. -[![CodeFactor](https://img.shields.io/codefactor/grade/github/CLDMV/holdmytask?style=for-the-badge&logo=codefactor&logoColor=white&labelColor=F44A6A)](https://www.codefactor.io/repository/github/cldmv/holdmytask) [![npms.io score](https://img.shields.io/npms-io/final-score/%40cldmv%2Fholdmytask?style=for-the-badge&logo=npms&logoColor=white&labelColor=0B5D57)](https://npms.io/search?q=%40cldmv%2Fholdmytask) - -[![npm unpacked size](https://img.shields.io/npm/unpacked-size/%40cldmv%2Fholdmytask.svg?style=for-the-badge&logo=npm&logoColor=white&labelColor=CB3837)](https://www.npmjs.com/package/@cldmv/holdmytask) [![Repo size](https://img.shields.io/github/repo-size/CLDMV/holdmytask?style=for-the-badge&logo=github&logoColor=white&labelColor=181717)](https://github.com/CLDMV/holdmytask) - ### Unlimited Queue Capacity For scenarios requiring unlimited task queuing, you can set `maxQueue` to `-1`: @@ -740,6 +812,8 @@ for (let i = 0; i < 100000; i++) { While `-1` allows unlimited queuing, be mindful of memory usage with very large task sets. Each queued task consumes memory until executed. +--- + ## 🎯 Advanced Features ### Priority System @@ -769,50 +843,50 @@ HoldMyTask supports comprehensive priority and coalescing configuration for soph ```javascript const queue = new HoldMyTask({ concurrency: 3, - // Priority-specific defaults (replaces legacy delays) + // Priority-specific defaults priorities: { - 1: { delay: 200, start: 0 }, // High priority: 200ms delay, immediate start - 2: { delay: 100, start: 25 }, // Medium priority: 100ms delay, 25ms start delay - 3: { delay: 50, start: 50 } // Low priority: 50ms delay, 50ms start delay + 1: { postDelay: 200, startDelay: 0 }, // High priority: 200ms delay, immediate start + 2: { postDelay: 100, startDelay: 25 }, // Medium priority: 100ms delay, 25ms start delay + 3: { postDelay: 50, startDelay: 50 } // Low priority: 50ms delay, 50ms start delay }, // Enhanced coalescing with per-key settings coalescing: { defaults: { windowDuration: 200, maxDelay: 1000, - delay: 75, // Default completion delay for coalescing tasks - start: 25, // Default start delay for coalescing tasks + postDelay: 75, // Default completion delay for coalescing tasks + startDelay: 25, // Default start delay for coalescing tasks resolveAllPromises: true }, keys: { "ui.update": { windowDuration: 100, maxDelay: 500, - delay: 25, // Fast UI updates - start: 0 + postDelay: 25, // Fast UI updates + startDelay: 0 }, "api.batch": { windowDuration: 1000, maxDelay: 5000, - delay: 200, // Slower API operations - start: 100 + postDelay: 200, // Slower API operations + startDelay: 100 } } } }); // Dynamic configuration -queue.configurePriority(4, { delay: 300, start: 75 }); +queue.configurePriority(4, { postDelay: 300, startDelay: 75 }); queue.configureCoalescingKey("data.sync", { windowDuration: 800, maxDelay: 3000, - delay: 150, - start: 50 + postDelay: 150, + startDelay: 50 }); // Get configuration information const priority4Config = queue.getPriorityConfig(4); -console.log(`Priority 4: ${priority4Config.delay}ms delay, ${priority4Config.start}ms start delay`); +console.log(`Priority 4: ${priority4Config.postDelay}ms delay, ${priority4Config.startDelay}ms start delay`); const dataSyncConfig = queue.getCoalescingConfig("data.sync"); console.log(`Data sync: ${dataSyncConfig.windowDuration}ms window, ${dataSyncConfig.maxDelay}ms max delay`); @@ -832,7 +906,7 @@ console.log("All coalescing configs:", allCoalescingKeys); 4. Coalescing defaults 5. System defaults (lowest priority) -**Backward Compatibility:** Legacy `delays` options are automatically converted to the new `priorities` format. +**Backward Compatibility:** Deprecated option names are converted automatically; see [Deprecated Options](#deprecated-options). ### Smart Scheduling @@ -983,10 +1057,10 @@ Priority delays create "cool-down" periods after task completion based on the co ```javascript const queue = new HoldMyTask({ concurrency: 1, - delays: { - 1: 1000, // 1 second delay after priority 1 tasks complete - 2: 500, // 500ms delay after priority 2 tasks complete - 3: 0 // No delay after priority 3 tasks (explicit) + priorities: { + 1: { postDelay: 1000 }, // 1 second delay after priority 1 tasks complete + 2: { postDelay: 500 }, // 500ms delay after priority 2 tasks complete + 3: { postDelay: 0 } // No delay after priority 3 tasks (explicit) } }); @@ -997,22 +1071,22 @@ const queue = new HoldMyTask({ // → Next task can't start until 10:00:07.5 (500ms delay) // Override delay for specific task -queue.enqueue(task, callback, { priority: 1, delay: 200 }); // Uses 200ms instead of 1000ms +queue.enqueue(task, callback, { priority: 1, postDelay: 200 }); // Uses 200ms instead of 1000ms // Set zero delay for specific task -queue.enqueue(task, callback, { priority: 1, delay: 0 }); // No delay after this task +queue.enqueue(task, callback, { priority: 1, postDelay: 0 }); // No delay after this task ``` ### Delay Bypass - Emergency Task Injection -When urgent tasks need to execute immediately, bypassing active delay periods, use the `bypassDelay` option or `delay: -1` syntax. This is perfect for emergency situations, high-priority interrupts, or critical system tasks. +When urgent tasks need to execute immediately, bypassing active delay periods, use the `bypassDelay` option or `postDelay: -1` syntax. This is perfect for emergency situations, high-priority interrupts, or critical system tasks. #### Bypass Behavior ```javascript const queue = new HoldMyTask({ concurrency: 1, - delays: { 1: 1000 } // 1 second delay after priority 1 tasks + priorities: { 1: { postDelay: 1000 } } // 1 second delay after priority 1 tasks }); // Timeline example: @@ -1030,7 +1104,7 @@ queue.enqueue(urgentTaskC, callback, { // Alternative bypass syntax queue.enqueue(emergencyTaskD, callback, { priority: 1, - delay: -1 // Same as bypassDelay: true + postDelay: -1 // Same as bypassDelay: true }); ``` @@ -1039,7 +1113,7 @@ queue.enqueue(emergencyTaskD, callback, { ```javascript const queue = new HoldMyTask({ concurrency: 2, - delays: { 1: 800, 2: 400 } + priorities: { 1: { postDelay: 800 }, 2: { postDelay: 400 } } }); // Multiple tasks with different bypass behavior @@ -1059,6 +1133,8 @@ queue.enqueue(taskD, callback, { priority: 1 }); // Waits for taskC's completion - ✅ **Concurrency aware** - works correctly with multiple concurrent execution slots - ⚠️ **Use sparingly** - frequent bypassing defeats the purpose of delay-based rate limiting +--- + ## 🔄 Task Coalescing System The coalescing system allows multiple similar tasks to be intelligently merged, reducing redundant operations while ensuring all promises resolve with accurate results. This is perfect for scenarios like UI updates, API calls, or device commands where only the final result matters. @@ -1072,7 +1148,7 @@ When tasks with the same `coalescingKey` are enqueued within a time window, they 3. **One representative task** executes for the entire group 4. **All promises** in the group resolve with the same result -⚠️ **Critical Timing Consideration**: Real-world tasks take time to execute (100ms-2000ms+). If your coalescing tasks need to see the final state from other operations, ensure proper timing with `start` delays or `timestamp` scheduling. Tasks that start too early may see intermediate states rather than final results. +⚠️ **Critical Timing Consideration**: Real-world tasks take time to execute (100ms-2000ms+). If your coalescing tasks need to see the final state from other operations, ensure proper timing with `startDelay` or `timestamp` scheduling. Tasks that start too early may see intermediate states rather than final results. ### 🎯 Correct Coalescing Pattern: Fire-and-Forget with Embedded Updates @@ -1121,9 +1197,13 @@ function volumeUp() { ```javascript const queue = new HoldMyTask({ concurrency: 1, - coalescingWindowDuration: 200, // 200ms window for grouping tasks - coalescingMaxDelay: 1000, // Maximum 1000ms delay before forcing execution - coalescingResolveAllPromises: true // All promises get the result (default: true) + coalescing: { + defaults: { + windowDuration: 200, // 200ms window for grouping tasks + maxDelay: 1000, // Maximum 1000ms delay before forcing execution + resolveAllPromises: true // All promises get the result (default: true) + } + } }); ``` @@ -1143,7 +1223,7 @@ async function updateVolume(change) { { coalescingKey: "volume.update", // Tasks with same key get grouped priority: 1, - delay: 100 // 100ms delay after completion + postDelay: 100 // 100ms delay after completion } ); } @@ -1169,8 +1249,12 @@ The correct pattern for coalescing with updates is to enqueue update tasks **fro ```javascript const queue = new HoldMyTask({ concurrency: 2, // Allow volume and update tasks to run concurrently - coalescingWindowDuration: 200, - coalescingMaxDelay: 1000 + coalescing: { + defaults: { + windowDuration: 200, + maxDelay: 1000 + } + } }); // Volume commands using fire-and-forget pattern @@ -1197,7 +1281,7 @@ function volumeUp(amount = 1) { { coalescingKey: "volume.ui.update", // UI updates get coalesced priority: 5, // Lower priority than volume commands - delay: 100 // Brief delay after UI updates + postDelay: 100 // Brief delay after UI updates } ); @@ -1205,7 +1289,7 @@ function volumeUp(amount = 1) { }, { priority: 1, // High priority for user actions - delay: 100 // Brief delay after volume operations + postDelay: 100 // Brief delay after volume operations } ); } @@ -1238,7 +1322,7 @@ async function processDataBatch(data) { { coalescingKey: "api.batch.process", priority: 2, - start: 100 // 100ms delay allows grouping + startDelay: 100 // 100ms delay allows grouping } ); } @@ -1255,12 +1339,16 @@ async function processDataBatch(data) { ### Coalescing with Explicit Timestamps -For precise scheduling, use `timestamp` instead of `start`: +For precise scheduling, use `timestamp` instead of `startDelay`: ```javascript const queue = new HoldMyTask({ - coalescingWindowDuration: 300, - coalescingMaxDelay: 2000 + coalescing: { + defaults: { + windowDuration: 300, + maxDelay: 2000 + } + } }); // Schedule all updates for the same exact time @@ -1294,8 +1382,12 @@ Control maximum delay with `mustRunBy` to ensure tasks don't wait too long: ```javascript const queue = new HoldMyTask({ - coalescingWindowDuration: 500, // Try to group for 500ms - coalescingMaxDelay: 2000 // But never wait more than 2 seconds + coalescing: { + defaults: { + windowDuration: 500, // Try to group for 500ms + maxDelay: 2000 // But never wait more than 2 seconds + } + } }); // Critical system updates @@ -1324,8 +1416,12 @@ Control how promises resolve within coalescing groups: ```javascript const queue = new HoldMyTask({ - coalescingResolveAllPromises: true, // Default: all promises get the result - coalescingWindowDuration: 200 + coalescing: { + defaults: { + resolveAllPromises: true, // Default: all promises get the result + windowDuration: 200 + } + } }); // Mode 1: All promises resolve (default behavior) @@ -1336,18 +1432,23 @@ const results1 = await Promise.all([ ]); // All three promises resolve with the same result -// Mode 2: Only representative promise resolves +// Mode 2: Only the representative (newest) task's promise resolves const queue2 = new HoldMyTask({ - coalescingResolveAllPromises: false, - coalescingWindowDuration: 200 + coalescing: { + defaults: { + resolveAllPromises: false, + windowDuration: 200 + } + } }); -const [result1, result2, result3] = await Promise.all([ - queue2.enqueue(task, { coalescingKey: "test" }), // Resolves with result - queue2.enqueue(task, { coalescingKey: "test" }), // Resolves with undefined - queue2.enqueue(task, { coalescingKey: "test" }) // Resolves with undefined +const settled = await Promise.allSettled([ + queue2.enqueue(task, { coalescingKey: "test" }), // Rejects: "Task was coalesced with a newer task" + queue2.enqueue(task, { coalescingKey: "test" }), // Rejects: "Task was coalesced with a newer task" + queue2.enqueue(task, { coalescingKey: "test" }) // Resolves with the result ]); -// Only the first (representative) promise gets the actual result +// Only the newest task in the group gets the result; the others are rejected +// (callback-style tasks receive the same error as their first argument) ``` ### Real-World Coalescing Patterns @@ -1359,8 +1460,12 @@ class VolumeController { constructor() { this.queue = new HoldMyTask({ concurrency: 2, // Allow volume and update tasks concurrently - coalescingWindowDuration: 200, - coalescingMaxDelay: 1000 + coalescing: { + defaults: { + windowDuration: 200, + maxDelay: 1000 + } + } }); } @@ -1383,7 +1488,7 @@ class VolumeController { { coalescingKey: "volume.ui.update", // Updates coalesce together priority: 3, // Lower priority than volume changes - delay: 100 // Brief delay after UI updates + postDelay: 100 // Brief delay after UI updates } ); @@ -1391,7 +1496,7 @@ class VolumeController { }, { priority: 1, // High priority for user actions - delay: 50 // Brief delay between volume operations + postDelay: 50 // Brief delay between volume operations } ); } @@ -1405,8 +1510,12 @@ class APIBatcher { constructor() { this.queue = new HoldMyTask({ concurrency: 2, - coalescingWindowDuration: 300, - coalescingMaxDelay: 1500 + coalescing: { + defaults: { + windowDuration: 300, + maxDelay: 1500 + } + } }); } @@ -1436,8 +1545,12 @@ Real-world performance improvements with the embedded update pattern: // Fire-and-forget pattern with embedded coalescing updates const queue = new HoldMyTask({ concurrency: 2, - coalescingWindowDuration: 200, - coalescingMaxDelay: 1000 + coalescing: { + defaults: { + windowDuration: 200, + maxDelay: 1000 + } + } }); // Volume control example - realistic embedded pattern @@ -1476,9 +1589,11 @@ function processVolumeCommands() { // - Total time: ~25-30 seconds (vs 50+ seconds without coalescing) ``` -### Timeouts +--- + +## ⏱️ Timeouts -Tasks automatically timeout and either call the callback with an error or reject the promise: +Tasks automatically time out and either call the callback with an error payload or reject the promise. Callback-style timeouts also emit an `error` event on the queue (see [Events](#events)): **Callback API:** @@ -1518,6 +1633,8 @@ try { } ``` +--- + ## 🛑 AbortController Support The library uses `AbortController` for cooperative task cancellation. This allows tasks to be cancelled gracefully without forcing termination. @@ -1679,6 +1796,8 @@ try { - **Cooperative**: Tasks must actively check and respond to the abort signal - **Async operations**: Only cancellable if the underlying operation supports AbortSignal +--- + ## 📝 Examples ### Basic Usage @@ -1712,10 +1831,10 @@ function processUser(userId) { ```javascript const queue = new HoldMyTask({ concurrency: 1, - delays: { - 1: 1000, // 1 second between high-priority tasks - 2: 100, // 100ms between medium-priority tasks - 3: 0 // No delay for low-priority tasks + priorities: { + 1: { postDelay: 1000 }, // 1 second between high-priority tasks + 2: { postDelay: 100 }, // 100ms between medium-priority tasks + 3: { postDelay: 0 } // No delay for low-priority tasks } }); @@ -1732,7 +1851,7 @@ queue.enqueue(mediumTask2, callback, { priority: 2 }); ```javascript const queue = new HoldMyTask({ concurrency: 5, - delays: { 1: 50 } // Small delay between batches + priorities: { 1: { postDelay: 50 } } // Small delay between batches }); async function processBatch(items) { @@ -1767,10 +1886,10 @@ Real-world scenario: API rate limiting with emergency override capability. ```javascript const apiQueue = new HoldMyTask({ concurrency: 2, - delays: { - 1: 2000, // 2 second delay between API calls (rate limiting) - 2: 5000, // 5 second delay for heavy operations - 9: 0 // No delay for monitoring tasks + priorities: { + 1: { postDelay: 2000 }, // 2 second delay between API calls (rate limiting) + 2: { postDelay: 5000 }, // 5 second delay for heavy operations + 9: { postDelay: 0 } // No delay for monitoring tasks } }); @@ -1793,7 +1912,7 @@ apiQueue.enqueue(processSecurityAlert, handleEmergency, { // Alternative syntax for bypass apiQueue.enqueue(emergencyShutdown, handleEmergency, { priority: 1, - delay: -1, // Same as bypassDelay: true + postDelay: -1, // Same as bypassDelay: true metadata: { action: "shutdown" } }); @@ -1813,23 +1932,26 @@ setInterval(() => { }, 1000); ``` +--- + ## 🧪 Testing & Development ```bash -# Run tests +# Run tests (vitest suite + CommonJS entry tests) npm test # Run tests with coverage -npm run test:coverage +npm run coverage # Run tests in watch mode -npm run test:run +npm run test:watch -# Lint code +# Lint and format check npm run lint +npm run format:check -# Build for publishing -npm run build +# Build types, type-check, and build for publishing +npm run build:ci ``` ### Testing with Injectable Clock @@ -1853,21 +1975,74 @@ console.log(Date.now()); // Returns actual system time queue.enqueue(task, { timestamp: queue.now() + 5000 }); // 5 seconds from mock time ``` -## 📄 License +--- + +## 📚 Documentation + +- **[Changelog](https://github.com/CLDMV/holdmytask/tree/master/docs/changelog/)** — release notes for every version (v1 + v2) +- **[Examples](https://github.com/CLDMV/holdmytask/tree/master/examples/)** — runnable scripts exercising priorities, coalescing, and stress scenarios +- **[Type definitions](https://github.com/CLDMV/holdmytask/tree/master/types/)** — the TypeScript declarations shipped with the package -[![GitHub license](https://img.shields.io/github/license/CLDMV/holdmytask.svg?style=for-the-badge&logo=github&logoColor=white&labelColor=181717)](https://github.com/CLDMV/holdmytask/blob/HEAD/LICENSE) [![npm license](https://img.shields.io/npm/l/%40cldmv%2Fholdmytask.svg?style=for-the-badge&logo=npm&logoColor=white&labelColor=CB3837)](https://www.npmjs.com/package/@cldmv/holdmytask) +[![CodeFactor]][codefactor_url] [![OpenSSF Scorecard]][ossf_scorecard_url] [![npms.io score]][npms_url] [![npm unpacked size]][npm_size_url] [![Repo size]][repo_size_url] -Apache License 2.0 - see [LICENSE](LICENSE) file for details. +--- ## 🤝 Contributing -Contributions welcome! Please ensure: +Contributions are welcome! Please ensure: - All tests pass - Code follows existing style - New features include tests - Documentation is updated +[![Contributors]][contributors_url] [![Sponsor shinrai]][sponsor_url] + --- -**Made with ❤️ for robust task management** +## 🔗 Links + +- **npm**: [@cldmv/holdmytask](https://www.npmjs.com/package/@cldmv/holdmytask) +- **GitHub**: [CLDMV/holdmytask](https://github.com/CLDMV/holdmytask) +- **Issues**: [GitHub Issues](https://github.com/CLDMV/holdmytask/issues) +- **Changelog**: [docs/changelog/](https://github.com/CLDMV/holdmytask/tree/master/docs/changelog/) +- **Releases**: [GitHub Releases](https://github.com/CLDMV/holdmytask/releases) + +--- + +## 📄 License + +[![GitHub license]][github_license_url] [![npm license]][npm_license_url] + +Apache-2.0 © Shinrai / CLDMV — see [LICENSE](https://github.com/CLDMV/holdmytask/blob/HEAD/LICENSE) for details. + +[npm version]: https://img.shields.io/npm/v/%40cldmv%2Fholdmytask.svg?style=for-the-badge&logo=npm&logoColor=white&labelColor=CB3837 +[npm_version_url]: https://www.npmjs.com/package/@cldmv/holdmytask +[last commit]: https://img.shields.io/github/last-commit/CLDMV/holdmytask?style=for-the-badge&logo=github&logoColor=white&labelColor=181717 +[last_commit_url]: https://github.com/CLDMV/holdmytask/commits +[npm last update]: https://img.shields.io/npm/last-update/%40cldmv%2Fholdmytask?style=for-the-badge&logo=npm&logoColor=white&labelColor=CB3837 +[npm_last_update_url]: https://www.npmjs.com/package/@cldmv/holdmytask +[codefactor]: https://img.shields.io/codefactor/grade/github/CLDMV/holdmytask?style=for-the-badge&logo=codefactor&logoColor=white&labelColor=F44A6A +[codefactor_url]: https://www.codefactor.io/repository/github/cldmv/holdmytask +[openssf scorecard]: https://img.shields.io/ossf-scorecard/github.com/CLDMV/holdmytask?style=for-the-badge&label=OpenSSF%20Scorecard +[ossf_scorecard_url]: https://scorecard.dev/viewer/?uri=github.com/CLDMV/holdmytask +[npms.io score]: https://img.shields.io/npms-io/final-score/%40cldmv%2Fholdmytask?style=for-the-badge&logo=npms&logoColor=white&labelColor=0B5D57 +[npms_url]: https://npms.io/search?q=%40cldmv%2Fholdmytask +[npm downloads]: https://img.shields.io/npm/dm/%40cldmv%2Fholdmytask.svg?style=for-the-badge&logo=npm&logoColor=white&labelColor=CB3837 +[npm_downloads_url]: https://www.npmjs.com/package/@cldmv/holdmytask +[github downloads]: https://img.shields.io/github/downloads/CLDMV/holdmytask/total?style=for-the-badge&logo=github&logoColor=white&labelColor=181717 +[github_downloads_url]: https://github.com/CLDMV/holdmytask/releases +[npm unpacked size]: https://img.shields.io/npm/unpacked-size/%40cldmv%2Fholdmytask.svg?style=for-the-badge&logo=npm&logoColor=white&labelColor=CB3837 +[npm_size_url]: https://www.npmjs.com/package/@cldmv/holdmytask +[repo size]: https://img.shields.io/github/repo-size/CLDMV/holdmytask?style=for-the-badge&logo=github&logoColor=white&labelColor=181717 +[repo_size_url]: https://github.com/CLDMV/holdmytask +[github license]: https://img.shields.io/github/license/CLDMV/holdmytask.svg?style=for-the-badge&logo=github&logoColor=white&labelColor=181717 +[github_license_url]: https://github.com/CLDMV/holdmytask/blob/HEAD/LICENSE +[npm license]: https://img.shields.io/npm/l/%40cldmv%2Fholdmytask.svg?style=for-the-badge&logo=npm&logoColor=white&labelColor=CB3837 +[npm_license_url]: https://www.npmjs.com/package/@cldmv/holdmytask +[coverage]: https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2FCLDMV%2Fholdmytask%2Fbadges%2Fcoverage.json&style=for-the-badge&logo=vitest&logoColor=white +[coverage_url]: https://github.com/CLDMV/holdmytask/blob/badges/coverage.json +[contributors]: https://img.shields.io/github/contributors/CLDMV/holdmytask.svg?style=for-the-badge&logo=github&logoColor=white&labelColor=181717 +[contributors_url]: https://github.com/CLDMV/holdmytask/graphs/contributors +[sponsor shinrai]: https://img.shields.io/github/sponsors/shinrai?style=for-the-badge&logo=githubsponsors&logoColor=white&labelColor=EA4AAA&label=Sponsor +[sponsor_url]: https://github.com/sponsors/shinrai diff --git a/docs/changelog/v2/v2.0.5.md b/docs/changelog/v2/v2.0.5.md new file mode 100644 index 0000000..e118831 --- /dev/null +++ b/docs/changelog/v2/v2.0.5.md @@ -0,0 +1,66 @@ +# HoldMyTask v2.0.5 Changelog + +**Release Date**: October 2026 +**Release Type**: Patch +**Branch**: `release/2.0.5` + +--- + +## Overview + +v2.0.5 fixes three long-standing problems in how the queue reads its options and reports failures. The documented task-level `postDelay` / `startDelay` options now actually work (the old `delay` / `start` names become deprecated aliases), a callback-style task failure no longer crashes the process when no `error` listener is attached, and a `priorities` key that is not an integer is now reported instead of being dropped silently. The README was rewritten to the CLDMV layout and every example now uses the current API. + +Runtime behavior changes in a few observable ways, all described below: code that still passes `delay` or `start` to `enqueue()` now receives a deprecation `warning` event, a task-level completion delay is now enforced even when the task's priority has no `postDelay` of its own, a callback-style task failure is no longer thrown as an unhandled `error` event, and a non-integer priority key now produces an `invalid-priority` warning. No option was removed and no exported signature changed. + +--- + +## 🐛 Bug Fixes + +### Task-level `postDelay` / `startDelay` are honored; `delay` / `start` are deprecated aliases ([#55](https://github.com/CLDMV/holdmytask/pull/55)) + +`enqueue()` only read the task-level `delay` and `start` options, although its JSDoc already pointed at `postDelay`. Passing the documented name gave no delay at all. `enqueue()` now treats `postDelay` and `startDelay` as the current task-level names, matching the names already used in `priorities`, `coalescing.defaults` and `coalescing.keys`. `delay` and `start` still work and map onto the new names; when both forms are given, the current name wins. `postDelay: -1` bypasses an active delay period exactly as `delay: -1` did, and the caller's options object is never mutated. `getPriorityConfig()` and `getCoalescingConfig()` accept both names in their `taskOptions` argument. + +**New warning:** each deprecated task-level alias now emits a `warning` event of type `"deprecation"` (with `deprecated` and `replacement` fields), in the same shape the priority and coalescing levels already used. The warning fires once per alias per queue instance, not once per task, so a hot enqueue path does not flood listeners. No warning is emitted when the replacement name is also present. + +**Scheduler fix:** the delay gate between tasks used to require the completed task's _priority_ to have a positive `postDelay`. A task-level completion delay on a priority without one was recorded but never enforced, which was true of the old `delay` name as well. The gate now relies on the computed next-available time alone, so a task-level `postDelay` (or `delay`) takes effect regardless of the priority's configuration. Code that relied on a task-level delay being ignored in that situation will now see the delay applied. Closes [#51](https://github.com/CLDMV/holdmytask/issues/51). + +### Callback-style task failures no longer require an `error` listener ([#56](https://github.com/CLDMV/holdmytask/pull/56)) + +When a callback-style task failed, timed out, was aborted or expired, the queue emitted `error`. `HoldMyTask` is an `EventEmitter`, so with no `error` listener that emit threw: the process crashed (an unhandled rejection for failures, an uncaught exception from the scheduler for expiry), and for failures the task's callback never ran. Task-failure `error` events are now emitted only when at least one `error` listener is attached. The failure always reaches the task's callback with the documented payload (`{ type: "error", error }`, `{ type: "timeout", message }`, `{ type: "canceled", message: "Task was aborted" }`, or the expiry error). + +Listeners that are attached see the same events as before. The difference is for queues without one: a callback task failure is no longer thrown. An error thrown by the callback itself is still emitted unconditionally, so a bug inside a callback is not silently swallowed, and the `error` events for invalid `enqueue()` arguments and rejected `configurePriority()` / `configureCoalescingKey()` input are unchanged. Promise-style tasks are unchanged and report failures only through the rejected promise. Closes [#53](https://github.com/CLDMV/holdmytask/issues/53). + +### Non-integer priority keys are reported instead of dropped silently ([#57](https://github.com/CLDMV/holdmytask/pull/57)) + +The constructor ran `priorities` (and the deprecated `delays`) keys through `parseInt` and discarded anything that came back `NaN`, so a config such as `priorities: { high: { postDelay: 100 } }` did nothing, and a key such as `"2.5"` was truncated without notice. Any non-integer key now emits a `warning` event of type `"invalid-priority"` with `message`, `option` (`"priorities"` or `"delays"`), `key` and `priority` fields. A key that cannot be read as a number is still ignored (`priority: null`); a fractional key keeps its historical meaning, the truncated priority, but is reported. Integer keys, including negative ones, are unaffected. The constructor still never throws for configuration, so a config that constructed fine on v2.0.4 still constructs fine. Closes [#54](https://github.com/CLDMV/holdmytask/issues/54). + +--- + +## 🧪 Tests + +- New suites: `TaskLevelDelayNames` ([#55](https://github.com/CLDMV/holdmytask/pull/55)), `CallbackErrorWithoutListener` ([#56](https://github.com/CLDMV/holdmytask/pull/56)) and `PriorityKeyValidation` ([#57](https://github.com/CLDMV/holdmytask/pull/57)). +- `PostDelayAfterAwait` ([#59](https://github.com/CLDMV/holdmytask/pull/59)) adds deterministic fake-timer coverage for `postDelay` when the next task is enqueued after awaiting the previous one: promise API after `await`, promise API in the resolving microtask, and callback API enqueuing from the completion callback, in both smart-scheduling and polling modes. All cases pass on the current code; the report in [#52](https://github.com/CLDMV/holdmytask/issues/52) could not be reproduced, and the issue is closed with these tests as regression coverage. + +## 📚 Documentation + +- The README was restructured to the CLDMV layout and every example was updated from deprecated option names to the current API ([#50](https://github.com/CLDMV/holdmytask/pull/50)). It now documents the task-level `postDelay` / `startDelay` options, the optional `error` listener for callback-style tasks, both `warning` event types (`deprecation` and `invalid-priority`), and a table of every deprecated option with its replacement. +- The JSDoc and generated `types/` declarations for `enqueue()` list `postDelay` and `startDelay`, with `delay` and `start` marked deprecated. +- **NEW:** [docs/changelog/v2/v2.0.5.md](./v2.0.5.md) — this changelog. + +## 🔧 Dependencies + +Both are dev dependencies and affect only the `npm run fix:headers` script, which extends `@cldmv/configs/fix-headers.json`. Only `package.json` and the lockfile changed; no file headers were restamped and the published package is unaffected. + +- `@cldmv/fix-headers` `^2.1.1` → `^2.2.0`, in two steps. 2.1.4 ([#61](https://github.com/CLDMV/holdmytask/pull/61)) no longer writes a JavaScript comment into JSON or Markdown files, processes every repeated `--input`, and never walks dependency folders such as `node_modules`. 2.2.0 ([#62](https://github.com/CLDMV/holdmytask/pull/62)) makes `@Last modified by` follow edits to a file's content only, so a run that merely rewrites a header keeps the recorded editor. +- `@cldmv/configs` `^1.2.0` → `^1.2.4` ([#62](https://github.com/CLDMV/holdmytask/pull/62)). The shared fix-headers config now sets `forceAuthorUpdate` and `forceLastModifiedAuthorUpdate` to `false`, so `@Author` keeps the file's creator and `@Last modified by` changes only with real content edits. The 1.2.1 and 1.2.2 releases of that package only changed its own CI. + +--- + +## Upgrade notes + +No breaking API changes — drop-in for [v2.0.4](./v2.0.4.md), with these behavior differences to check: + +- Replace task-level `delay` with `postDelay` and `start` with `startDelay` in `enqueue()` calls. The old names keep working, but each emits one `deprecation` warning per queue instance. +- A task-level `postDelay` (or `delay`) is now enforced even when the task's priority has no `postDelay`. If a queue depended on that delay being ignored, remove it from the task options. +- A callback-style task failure is no longer thrown when no `error` listener is attached; handle failures in the task callback, or attach an `error` listener if the queue-level event is wanted. +- Check `priorities` / `delays` configs for non-integer keys. They behave as before, but now emit an `invalid-priority` warning. diff --git a/package-lock.json b/package-lock.json index e9fb258..7f5d0ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "@cldmv/holdmytask", - "version": "2.0.4", + "version": "2.0.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cldmv/holdmytask", - "version": "2.0.4", + "version": "2.0.5", "license": "Apache-2.0", "devDependencies": { - "@cldmv/configs": "^1.2.0", - "@cldmv/fix-headers": "^2.1.1", + "@cldmv/configs": "^1.2.4", + "@cldmv/fix-headers": "^2.2.0", "@cldmv/vitest-runner": "^1.2.0", "@eslint/css": "^2.0.0", "@eslint/js": "^10.0.1", @@ -120,9 +120,9 @@ } }, "node_modules/@cldmv/configs": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@cldmv/configs/-/configs-1.2.0.tgz", - "integrity": "sha512-FDmlxOx6ceKuD5zTamUy9XOfAC4opeOaLxWqZz9okKxj8TsTZP6dN7W0VccRYRdw4606NvXjhdZqOPibcNpXmQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@cldmv/configs/-/configs-1.2.4.tgz", + "integrity": "sha512-7HPqAgCKqol3fHpawEsXJ5ZqHxlDPZk1puoFu43f/gaNfhWwufDTzjkvLk90yVEumyz4N3pUwIxfbHUkUTpDEg==", "dev": true, "license": "Apache-2.0", "funding": { @@ -131,9 +131,9 @@ } }, "node_modules/@cldmv/fix-headers": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@cldmv/fix-headers/-/fix-headers-2.1.1.tgz", - "integrity": "sha512-08xW44RvtrKUCTOjUFKHyrDvx6rT70zGqgRR+tdW0R9ElZrHwSg25xCRrZD+U7Dmj5fK9KmtuOWPjZtJYSfzYA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cldmv/fix-headers/-/fix-headers-2.2.0.tgz", + "integrity": "sha512-EQTAKCo0B639q2bde+vO5ciYbtvNgAJFm0DBthIJQnmTFJmQDUlObp5EsTRgg5CUlFoUnGMX+q35LC02QvYU4w==", "dev": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 1a82277..915d04d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cldmv/holdmytask", - "version": "2.0.4", + "version": "2.0.5", "description": "A tiny task queue that waits until your task is ready", "main": "./index.cjs", "module": "./index.mjs", @@ -82,8 +82,8 @@ }, "type": "module", "devDependencies": { - "@cldmv/configs": "^1.2.0", - "@cldmv/fix-headers": "^2.1.1", + "@cldmv/configs": "^1.2.4", + "@cldmv/fix-headers": "^2.2.0", "@cldmv/vitest-runner": "^1.2.0", "@eslint/css": "^2.0.0", "@eslint/js": "^10.0.1", diff --git a/src/hold-my-task.mjs b/src/hold-my-task.mjs index d786fa2..c10af99 100644 --- a/src/hold-my-task.mjs +++ b/src/hold-my-task.mjs @@ -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) @@ -140,6 +140,61 @@ export class HoldMyTask extends EventEmitter { return transformed; } + /** + * Normalizes task-level delay option names passed to enqueue(). + * `postDelay` / `startDelay` are the current names; `delay` / `start` are deprecated aliases. + * When both forms are given, the current name wins. Each deprecated alias emits one + * deprecation `warning` event per queue instance (not one per task), so a hot enqueue + * path doesn't flood listeners. + * @param {Object} options - Task options (never mutated) + * @returns {Object} A copy of the options using the current names + * @private + * @internal + */ + _normalizeTaskDelayOptions(options) { + if (!("delay" in options) && !("start" in options)) { + return options; + } + + const normalized = { ...options }; + const aliases = [ + { deprecated: "start", replacement: "startDelay" }, + { deprecated: "delay", replacement: "postDelay" } + ]; + + for (const { deprecated, replacement } of aliases) { + if (!(deprecated in options)) continue; + if (!(replacement in options)) { + normalized[replacement] = options[deprecated]; + this._warnTaskOptionDeprecated(deprecated, replacement); + } + delete normalized[deprecated]; + } + + return normalized; + } + + /** + * Emits a deprecation warning for a task-level option, once per option name per instance. + * @param {string} deprecated - Deprecated option name + * @param {string} replacement - Current option name + * @returns {void} + * @private + * @internal + */ + _warnTaskOptionDeprecated(deprecated, replacement) { + if (this._warnedTaskOptions.has(deprecated)) return; + this._warnedTaskOptions.add(deprecated); + setImmediate(() => + this.emit("warning", { + type: "deprecation", + message: `Task option '${deprecated}' is deprecated. Use '${replacement}' instead.`, + deprecated, + replacement + }) + ); + } + constructor(options = {}) { super(); @@ -155,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 @@ -249,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); } } @@ -270,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 @@ -350,6 +442,9 @@ export class HoldMyTask extends EventEmitter { this.coalescingRepresentatives = new Map(); // representativeTaskId -> { coalescingKey, groupId } this.nextGroupId = 1; + // Deprecated task-level option names already warned about (one warning per name per instance) + this._warnedTaskOptions = new Set(); + if (this.options.autoStart) { this.resume(); } @@ -378,11 +473,13 @@ export class HoldMyTask extends EventEmitter { * @param {string|number} [options.id] - Custom task ID for identification and later reference (must be unique) * @param {number} [options.priority] - Task priority (higher numbers run first) * @param {number} [options.timestamp] - When the task should be ready to run (milliseconds since epoch) - * @param {number} [options.start] - Milliseconds from now when the task should be ready to run (convenience for timestamp calculation) + * @param {number} [options.startDelay] - Milliseconds from now when the task should be ready to run (convenience for timestamp calculation). Overrides the priority's startDelay + * @param {number} [options.start] - DEPRECATED: Use startDelay instead * @param {AbortSignal} [options.signal] - AbortSignal to cancel the task * @param {number} [options.timeout] - Task timeout in milliseconds (for execution time limit) * @param {number} [options.expire] - Task expiration timestamp or milliseconds from now (for queue waiting time limit) - * @param {number} [options.delay] - DEPRECATED: Use postDelay instead. Delay after task completion before next task of same priority + * @param {number} [options.postDelay] - Delay after this task completes before the next task can start. Overrides the priority's postDelay; use -1 to bypass the current delay period + * @param {number} [options.delay] - DEPRECATED: Use postDelay instead * @param {boolean} [options.bypassDelay] - If true, skip any active delay period and start immediately * @param {string} [options.coalescingKey] - Key for task coalescing - tasks with same key will be coalesced within windows * @param {number} [options.coalescingWindowDuration] - Override coalescing window duration (task-level override of key-level and defaults) @@ -408,8 +505,8 @@ export class HoldMyTask extends EventEmitter { * // Bypass current delay for urgent task * const urgent = queue.enqueue(urgentTask, { priority: 10, bypassDelay: true }); * - * // Alternative: use delay: -1 to bypass - * const urgent2 = queue.enqueue(urgentTask, { priority: 10, delay: -1 }); + * // Alternative: use postDelay: -1 to bypass + * const urgent2 = queue.enqueue(urgentTask, { priority: 10, postDelay: -1 }); * * // Coalescing tasks - multiple device status checks become one * queue.enqueue(checkDeviceStatus, callback1, { coalescingKey: "device-123", coalescingWindowDuration: 1000 }); @@ -438,6 +535,9 @@ export class HoldMyTask extends EventEmitter { finalOptions = { ...optionsOrCallback, ...options }; } + // Task-level delay names: postDelay/startDelay are current, delay/start are deprecated aliases + finalOptions = this._normalizeTaskDelayOptions(finalOptions); + // Generate ID - use custom ID if provided, otherwise auto-generate const id = finalOptions.id ? String(finalOptions.id) : String(this.nextId++); @@ -457,7 +557,7 @@ export class HoldMyTask extends EventEmitter { // Regular task handling - apply priority defaults for start delay const priority = finalOptions.priority ?? this.options.defaultPriority; const priorityConfig = this.getPriorityConfig(priority, finalOptions); - const effectiveStart = finalOptions.start ?? priorityConfig.startDelay ?? 0; + const effectiveStart = priorityConfig.startDelay ?? 0; const readyAt = finalOptions.timestamp ?? (effectiveStart ? now + effectiveStart : now); // Calculate expiration timestamp @@ -484,8 +584,8 @@ export class HoldMyTask extends EventEmitter { status: "pending", signal: finalOptions.signal, timeout: finalOptions.timeout, - delay: finalOptions.delay, // completion delay - bypassDelay: finalOptions.bypassDelay || finalOptions.delay === -1, // bypass current delay period + delay: finalOptions.postDelay, // task-level completion delay override (undefined = use the priority's postDelay) + bypassDelay: finalOptions.bypassDelay || finalOptions.postDelay === -1, // bypass current delay period metadata: finalOptions.metadata }; @@ -620,14 +720,14 @@ export class HoldMyTask extends EventEmitter { const priorityConfig = this.getPriorityConfig(priority, options); // Apply configuration priority: task options > coalescing key config > priority defaults > coalescing defaults - const effectiveStart = options.start ?? coalescingConfig.startDelay ?? priorityConfig.startDelay ?? 0; + const effectiveStart = options.startDelay ?? coalescingConfig.startDelay ?? priorityConfig.startDelay ?? 0; const readyAt = options.timestamp ?? (effectiveStart ? now + effectiveStart : now); const windowEnd = now + coalescingConfig.windowDuration; const mustRunBy = now + coalescingConfig.maxDelay; // Apply effective delay configuration - const effectiveDelay = options.delay ?? coalescingConfig.postDelay ?? priorityConfig.postDelay; + const effectiveDelay = options.postDelay ?? coalescingConfig.postDelay ?? priorityConfig.postDelay; // Create task item (not added to main queue directly) const taskItem = { @@ -640,7 +740,7 @@ export class HoldMyTask extends EventEmitter { signal: options.signal, timeout: options.timeout, delay: effectiveDelay, - bypassDelay: options.bypassDelay || options.delay === -1, + bypassDelay: options.bypassDelay || options.postDelay === -1, metadata: options.metadata, coalescingKey, enqueueSeq: this.enqueueSeq++ @@ -1671,8 +1771,9 @@ export class HoldMyTask extends EventEmitter { // Start tasks up to concurrency limits (both global and per-priority) let hasWaitingTasks = false; const currentTime = this.now(); - const delay = this.lastCompletedPriority !== null ? (this.options.priorities[this.lastCompletedPriority]?.postDelay ?? 0) : 0; - const delayActive = delay > 0 && currentTime < this.nextAvailableTime; + // nextAvailableTime is only set (non-zero) when the completed task's effective delay - its own + // postDelay override or its priority's postDelay - was positive, so it alone decides the gate. + const delayActive = currentTime < this.nextAvailableTime; while (this.readyHeap.size() > 0) { const task = this.readyHeap.peek(); @@ -1835,20 +1936,37 @@ export class HoldMyTask extends EventEmitter { error.taskId = item.id; if (item.callback) { - // Callback API - emit error event + // Callback API - deliver the failure to the callback try { item.callback(error, null); } catch (err) { this.emit("error", { error: err, task: item }); } - // Emit error event for callback API - this.emit("error", { error, task: item }); + // Also report it as a queue "error" event, but only to listeners: the callback already + // has the failure, so an unhandled-"error" throw would only crash the caller's process. + this._emitTaskError({ error, task: item }); } else if (item.reject) { // Promise API - just reject, don't emit error event (handled by promise) item.reject(error); } } + /** + * Emits a task failure as an `error` event when, and only when, a listener is attached. + * Used for callback-API task failures, which are always delivered to the task's callback + * as well; with no listener, EventEmitter would otherwise throw the event and crash the + * process before (or after) the callback could handle it. + * @param {Object} payload - Event payload + * @returns {void} + * @private + * @internal + */ + _emitTaskError(payload) { + if (this.listenerCount("error") > 0) { + this.emit("error", payload); + } + } + /** * Starts execution of a ready task. * @param {Object} item - The task item to execute @@ -1934,9 +2052,10 @@ export class HoldMyTask extends EventEmitter { : "error"; item.finishedAt = this.now(); - // Emit error event only for callback API (promise API conveys error via rejection) + // Emit error event only for callback API (promise API conveys error via rejection), + // and only to listeners - the callback below always receives the failure. if (item.callback) { - this.emit("error", item); + this._emitTaskError(item); } // Store final values on promise handle before rejecting (for promise API) @@ -2050,8 +2169,7 @@ export class HoldMyTask extends EventEmitter { // Check if we have ready tasks that can run immediately if (this.readyHeap.size() > 0 && this.running.size < this.options.concurrency) { - const delay = this.lastCompletedPriority !== null ? (this.options.priorities[this.lastCompletedPriority]?.postDelay ?? 0) : 0; - const delayActive = delay > 0 && now < this.nextAvailableTime; + const delayActive = now < this.nextAvailableTime; if (!delayActive) { shouldRunNow = true; @@ -2246,7 +2364,7 @@ export class HoldMyTask extends EventEmitter { * // Check with task-level overrides * const effectiveConfig = queue.getCoalescingConfig('ui.update', { * coalescingWindowDuration: 50, - * delay: 30 // Still accepts old property names for backwards compatibility + * postDelay: 30 // Deprecated task-level name delay is still accepted * }); */ getCoalescingConfig(coalescingKey, taskOptions = {}) { @@ -2254,8 +2372,9 @@ export class HoldMyTask extends EventEmitter { const defaults = this.options.coalescing.defaults; const priorityConfig = this.getPriorityConfig(taskOptions.priority ?? this.options.defaultPriority); - const postDelay = taskOptions.delay ?? keyConfig.postDelay ?? priorityConfig.postDelay ?? defaults.postDelay; - const startDelay = taskOptions.start ?? keyConfig.startDelay ?? priorityConfig.startDelay ?? defaults.startDelay; + const postDelay = taskOptions.postDelay ?? taskOptions.delay ?? keyConfig.postDelay ?? priorityConfig.postDelay ?? defaults.postDelay; + const startDelay = + taskOptions.startDelay ?? taskOptions.start ?? keyConfig.startDelay ?? priorityConfig.startDelay ?? defaults.startDelay; return { windowDuration: taskOptions.coalescingWindowDuration ?? keyConfig.windowDuration ?? defaults.windowDuration, @@ -2356,15 +2475,15 @@ export class HoldMyTask extends EventEmitter { * * // Check with task-level overrides * const effectiveConfig = queue.getPriorityConfig(1, { - * delay: 50, - * start: 10 + * postDelay: 50, + * startDelay: 10 // Deprecated task-level names delay/start are still accepted * }); */ getPriorityConfig(priority, taskOptions = {}) { const priorityConfig = this.options.priorities[priority] || {}; - const postDelay = taskOptions.delay ?? priorityConfig.postDelay; - const startDelay = taskOptions.start ?? priorityConfig.startDelay; + const postDelay = taskOptions.postDelay ?? taskOptions.delay ?? priorityConfig.postDelay; + const startDelay = taskOptions.startDelay ?? taskOptions.start ?? priorityConfig.startDelay; return { // New clear property names diff --git a/tests/CallbackErrorWithoutListener.test.vitest.mjs b/tests/CallbackErrorWithoutListener.test.vitest.mjs new file mode 100644 index 0000000..3db9dba --- /dev/null +++ b/tests/CallbackErrorWithoutListener.test.vitest.mjs @@ -0,0 +1,103 @@ +/** + * + * @Project: @cldmv/holdmytask + * @Filename: /tests/CallbackErrorWithoutListener.test.vitest.mjs + * @Date: 2026-10-03T17:20:10-07:00 (1791073210) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-10-03T17:23:55-07:00 (1791073435) + * ----- + * @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"; + +// #53: a callback-style task failure must reach the callback even when nothing listens +// for the queue's `error` event; the event is only emitted when a listener exists. +const runCallbackTask = (q, task, options = {}) => + new Promise((resolve) => { + q.enqueue(task, (err, result) => resolve({ err, result }), options); + }); + +describe.each([ + { smartScheduling: true, mode: "Smart Scheduling" }, + { smartScheduling: false, mode: "Traditional Polling" } +])("callback task failures without an error listener ($mode)", ({ smartScheduling }) => { + test("a failing task delivers { type: 'error', error } to the callback without throwing", async () => { + const q = new HoldMyTask({ smartScheduling }); + const boom = new Error("boom"); + expect(q.listenerCount("error")).toBe(0); + const { err, result } = await runCallbackTask(q, () => { + throw boom; + }); + expect(err).toEqual({ type: "error", error: boom }); + expect(result).toBeNull(); + q.destroy(); + }); + + test("a timed-out task delivers { type: 'timeout', message } to the callback without throwing", async () => { + const q = new HoldMyTask({ smartScheduling }); + const { err, result } = await runCallbackTask(q, () => new Promise((resolve) => setTimeout(resolve, 500)), { timeout: 20 }); + expect(err).toEqual({ type: "timeout", message: "Task timed out after 20ms" }); + expect(result).toBeNull(); + q.destroy(); + }); + + test("an aborted task delivers { type: 'canceled', message: 'Task was aborted' } to the callback without throwing", async () => { + const q = new HoldMyTask({ smartScheduling }); + const { err, result } = await runCallbackTask(q, () => { + const abort = new Error("The operation was aborted"); + abort.name = "AbortError"; + throw abort; + }); + expect(err).toEqual({ type: "canceled", message: "Task was aborted" }); + expect(result).toBeNull(); + q.destroy(); + }); + + test("an expired task delivers its expire error to the callback without throwing", async () => { + const q = new HoldMyTask({ smartScheduling, concurrency: 1 }); + q.enqueue( + () => new Promise((resolve) => setTimeout(resolve, 60)), + () => {} + ); + const { err, result } = await runCallbackTask(q, () => "never", { expire: 10 }); + expect(err).toBeInstanceOf(Error); + expect(err.type).toBe("expire"); + expect(result).toBeNull(); + q.destroy(); + }); + + test("with an error listener attached, the event is still emitted and the callback still runs", async () => { + const q = new HoldMyTask({ smartScheduling }); + const events = []; + q.on("error", (payload) => events.push(payload)); + const boom = new Error("boom"); + const { err } = await runCallbackTask(q, () => { + throw boom; + }); + expect(err).toEqual({ type: "error", error: boom }); + expect(events).toHaveLength(1); + expect(events[0].error).toBe(boom); + expect(events[0].status).toBe("error"); + q.destroy(); + }); + + test("with an error listener attached, an expired callback task is still reported", async () => { + const q = new HoldMyTask({ smartScheduling, concurrency: 1 }); + const events = []; + q.on("error", (payload) => events.push(payload)); + q.enqueue( + () => new Promise((resolve) => setTimeout(resolve, 60)), + () => {} + ); + const { err } = await runCallbackTask(q, () => "never", { expire: 10 }); + expect(err.type).toBe("expire"); + expect(events.some((e) => e.error === err)).toBe(true); + q.destroy(); + }); +}); diff --git a/tests/PostDelayAfterAwait.test.vitest.mjs b/tests/PostDelayAfterAwait.test.vitest.mjs new file mode 100644 index 0000000..7bc858d --- /dev/null +++ b/tests/PostDelayAfterAwait.test.vitest.mjs @@ -0,0 +1,101 @@ +/** + * + * @Project: @cldmv/holdmytask + * @Filename: /tests/PostDelayAfterAwait.test.vitest.mjs + * @Date: 2026-10-03T17:26:43-07:00 (1791073603) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-10-03T17:26:45-07:00 (1791073605) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + * + */ + +import { test, expect, describe, vi, beforeEach, afterEach } from "vitest"; +import { HoldMyTask } from "../src/hold-my-task.mjs"; + +// Regression coverage for #52: a task enqueued immediately after awaiting the previous +// task (same priority) must still wait out the priority postDelay. +describe.each([ + { smartScheduling: true, mode: "Smart Scheduling" }, + { smartScheduling: false, mode: "Traditional Polling" } +])("priority postDelay after await with $mode", ({ smartScheduling }) => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "setImmediate", "clearImmediate", "Date"] }); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + test("promise API: enqueue right after await honours postDelay", async () => { + const q = new HoldMyTask({ concurrency: 1, smartScheduling, priorities: { 1: { postDelay: 300 } } }); + try { + const first = q.enqueue(() => "a", { priority: 1 }); + await vi.advanceTimersByTimeAsync(50); + await first; + const finishedAt = first.finishedAt; + + // 50ms of the 300ms postDelay has already elapsed; the next 200ms stay inside it. + const second = q.enqueue(() => "b", { priority: 1 }); + await vi.advanceTimersByTimeAsync(200); + expect(second.status()).not.toBe("completed"); + + await vi.advanceTimersByTimeAsync(200); + await second; + expect(second.startedAt - finishedAt).toBeGreaterThanOrEqual(300); + } finally { + q.destroy(); + } + }); + + test("promise API: enqueue in the same microtask as resolution honours postDelay", async () => { + const q = new HoldMyTask({ concurrency: 1, smartScheduling, priorities: { 1: { postDelay: 300 } } }); + try { + let second; + let finishedAt; + const first = q.enqueue(() => "a", { priority: 1 }); + first.then(() => { + finishedAt = first.finishedAt; + second = q.enqueue(() => "b", { priority: 1 }); + }); + await vi.advanceTimersByTimeAsync(50); + expect(second).toBeDefined(); + await vi.advanceTimersByTimeAsync(400); + await second; + expect(second.startedAt - finishedAt).toBeGreaterThanOrEqual(300); + } finally { + q.destroy(); + } + }); + + test("callback API: enqueue from inside the completion callback honours postDelay", async () => { + const q = new HoldMyTask({ concurrency: 1, smartScheduling, priorities: { 1: { postDelay: 300 } } }); + try { + let finishedAt; + let secondStartedAt; + q.enqueue( + () => "a", + (err) => { + expect(err).toBeNull(); + finishedAt = Date.now(); + q.enqueue( + () => { + secondStartedAt = Date.now(); + return "b"; + }, + () => {}, + { priority: 1 } + ); + }, + { priority: 1 } + ); + await vi.advanceTimersByTimeAsync(500); + expect(secondStartedAt).toBeDefined(); + expect(secondStartedAt - finishedAt).toBeGreaterThanOrEqual(300); + } finally { + q.destroy(); + } + }); +}); diff --git a/tests/PriorityKeyValidation.test.vitest.mjs b/tests/PriorityKeyValidation.test.vitest.mjs new file mode 100644 index 0000000..d54aa21 --- /dev/null +++ b/tests/PriorityKeyValidation.test.vitest.mjs @@ -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 + * @Email: + * ----- + * @Last modified by: Nate Corcoran (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(); + }); +}); diff --git a/tests/TaskLevelDelayNames.test.vitest.mjs b/tests/TaskLevelDelayNames.test.vitest.mjs new file mode 100644 index 0000000..87194b7 --- /dev/null +++ b/tests/TaskLevelDelayNames.test.vitest.mjs @@ -0,0 +1,177 @@ +/** + * + * @Project: @cldmv/holdmytask + * @Filename: /tests/TaskLevelDelayNames.test.vitest.mjs + * @Date: 2026-10-03T17:15:23-07:00 (1791072923) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-10-03T17:19:29-07:00 (1791073169) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + * + */ + +import { test, expect, describe, vi, beforeEach, afterEach } from "vitest"; +import { HoldMyTask } from "../src/hold-my-task.mjs"; + +// #51: task-level `postDelay` / `startDelay` are the current names; `delay` / `start` +// remain as deprecated aliases that emit a deprecation warning. +const fakeTimers = () => + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "setImmediate", "clearImmediate", "Date"] }); + +describe.each([ + { smartScheduling: true, mode: "Smart Scheduling" }, + { smartScheduling: false, mode: "Traditional Polling" } +])("task-level delay names with $mode", ({ smartScheduling }) => { + beforeEach(fakeTimers); + afterEach(() => vi.useRealTimers()); + + test("task-level postDelay delays the next task without any priority config", async () => { + const q = new HoldMyTask({ concurrency: 1, smartScheduling }); + try { + const a = q.enqueue(() => "a", { postDelay: 200 }); + const b = q.enqueue(() => "b"); + await vi.advanceTimersByTimeAsync(150); + expect(a.status()).toBe("completed"); + expect(b.status()).not.toBe("completed"); + await vi.advanceTimersByTimeAsync(150); + await Promise.all([a, b]); + expect(b.startedAt - a.finishedAt).toBeGreaterThanOrEqual(200); + } finally { + q.destroy(); + } + }); + + test("deprecated task-level delay still delays the next task and warns once", async () => { + const q = new HoldMyTask({ concurrency: 1, smartScheduling }); + const warnings = []; + q.on("warning", (w) => warnings.push(w)); + try { + const a = q.enqueue(() => "a", { delay: 200 }); + const b = q.enqueue(() => "b", { delay: 0 }); + await vi.advanceTimersByTimeAsync(400); + await Promise.all([a, b]); + expect(b.startedAt - a.finishedAt).toBeGreaterThanOrEqual(200); + const delayWarnings = warnings.filter((w) => w.deprecated === "delay"); + expect(delayWarnings).toHaveLength(1); + expect(delayWarnings[0]).toMatchObject({ type: "deprecation", deprecated: "delay", replacement: "postDelay" }); + expect(delayWarnings[0].message).toContain("deprecated"); + } finally { + q.destroy(); + } + }); + + test("task-level startDelay holds the task back; deprecated start does the same and warns", async () => { + const q = new HoldMyTask({ concurrency: 2, smartScheduling }); + const warnings = []; + q.on("warning", (w) => warnings.push(w)); + try { + const enqueuedAt = Date.now(); + const a = q.enqueue(() => "a", { startDelay: 100 }); + const b = q.enqueue(() => "b", { start: 100 }); + await vi.advanceTimersByTimeAsync(50); + expect(a.status()).toBe("pending"); + expect(b.status()).toBe("pending"); + await vi.advanceTimersByTimeAsync(100); + await Promise.all([a, b]); + expect(a.startedAt - enqueuedAt).toBeGreaterThanOrEqual(100); + expect(b.startedAt - enqueuedAt).toBeGreaterThanOrEqual(100); + expect(warnings.filter((w) => w.deprecated === "start")).toEqual([ + expect.objectContaining({ type: "deprecation", deprecated: "start", replacement: "startDelay" }) + ]); + expect(warnings.filter((w) => w.deprecated === "startDelay" || w.deprecated === "postDelay")).toHaveLength(0); + } finally { + q.destroy(); + } + }); + + test("postDelay: -1 bypasses an active delay period", async () => { + const q = new HoldMyTask({ concurrency: 1, smartScheduling, priorities: { 1: { postDelay: 500 } } }); + try { + const a = q.enqueue(() => "a", { priority: 1 }); + await vi.advanceTimersByTimeAsync(30); + await a; + const b = q.enqueue(() => "b", { priority: 1, postDelay: -1 }); + await vi.advanceTimersByTimeAsync(50); + expect(b.status()).toBe("completed"); + await b; + } finally { + q.destroy(); + } + }); +}); + +describe("task-level delay name resolution", () => { + test("new names win over deprecated aliases without a warning, and options are not mutated", async () => { + const q = new HoldMyTask({ concurrency: 1, autoStart: false }); + const warnings = []; + q.on("warning", (w) => warnings.push(w)); + const options = { postDelay: 10, delay: 999, startDelay: 5, start: 999 }; + const p = q.enqueue(() => "x", options); + expect(options).toEqual({ postDelay: 10, delay: 999, startDelay: 5, start: 999 }); + await new Promise((resolve) => setImmediate(resolve)); + expect(warnings).toHaveLength(0); + q.resume(); + await p; + q.destroy(); + }); + + test("callback-form options are normalized too", async () => { + const q = new HoldMyTask({ concurrency: 1 }); + const warnings = []; + q.on("warning", (w) => warnings.push(w)); + const result = await new Promise((resolve) => + q.enqueue( + () => "cb", + (err, value) => resolve(value), + { delay: 0 } + ) + ); + expect(result).toBe("cb"); + expect(warnings.some((w) => w.deprecated === "delay")).toBe(true); + q.destroy(); + }); + + test("getPriorityConfig and getCoalescingConfig honour task-level postDelay/startDelay and the aliases", () => { + const q = new HoldMyTask({ autoStart: false, priorities: { 1: { postDelay: 100, startDelay: 20 } } }); + expect(q.getPriorityConfig(1, { postDelay: 5, startDelay: 6 })).toMatchObject({ postDelay: 5, startDelay: 6, delay: 5, start: 6 }); + expect(q.getPriorityConfig(1, { delay: 7, start: 8 })).toMatchObject({ postDelay: 7, startDelay: 8 }); + expect(q.getPriorityConfig(1, { postDelay: 1, delay: 2 }).postDelay).toBe(1); + expect(q.getPriorityConfig(1)).toMatchObject({ postDelay: 100, startDelay: 20 }); + expect(q.getCoalescingConfig("k", { postDelay: 9, startDelay: 3 })).toMatchObject({ postDelay: 9, startDelay: 3 }); + expect(q.getCoalescingConfig("k", { delay: 4, start: 2 })).toMatchObject({ postDelay: 4, startDelay: 2 }); + q.destroy(); + }); + + test("coalescing tasks honour task-level postDelay/startDelay", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "setImmediate", "clearImmediate", "Date"] }); + const q = new HoldMyTask({ concurrency: 1, coalescing: { defaults: { windowDuration: 10, maxDelay: 50 } } }); + try { + const enqueuedAt = Date.now(); + let aRanAt; + let bRanAt; + const a = q.enqueue( + () => { + aRanAt = Date.now(); + return "a"; + }, + { coalescingKey: "k", startDelay: 100, postDelay: 200 } + ); + await vi.advanceTimersByTimeAsync(150); + await a; + const b = q.enqueue(() => { + bRanAt = Date.now(); + return "b"; + }); + await vi.advanceTimersByTimeAsync(1000); + await b; + expect(aRanAt - enqueuedAt).toBeGreaterThanOrEqual(100); + expect(bRanAt - aRanAt).toBeGreaterThanOrEqual(200); + } finally { + q.destroy(); + vi.useRealTimers(); + } + }); +}); diff --git a/types/src/hold-my-task.d.mts b/types/src/hold-my-task.d.mts index e7ff006..59ba973 100644 --- a/types/src/hold-my-task.d.mts +++ b/types/src/hold-my-task.d.mts @@ -67,6 +67,7 @@ export declare class HoldMyTask extends EventEmitter { coalescingGroups: Map | undefined; coalescingRepresentatives: Map | undefined; nextGroupId: number | undefined; + _warnedTaskOptions: Set | undefined; timeoutId: number | undefined; constructor(options?: {}); /** @@ -107,11 +108,13 @@ export declare class HoldMyTask extends EventEmitter { * @param {string|number} [options.id] - Custom task ID for identification and later reference (must be unique) * @param {number} [options.priority] - Task priority (higher numbers run first) * @param {number} [options.timestamp] - When the task should be ready to run (milliseconds since epoch) - * @param {number} [options.start] - Milliseconds from now when the task should be ready to run (convenience for timestamp calculation) + * @param {number} [options.startDelay] - Milliseconds from now when the task should be ready to run (convenience for timestamp calculation). Overrides the priority's startDelay + * @param {number} [options.start] - DEPRECATED: Use startDelay instead * @param {AbortSignal} [options.signal] - AbortSignal to cancel the task * @param {number} [options.timeout] - Task timeout in milliseconds (for execution time limit) * @param {number} [options.expire] - Task expiration timestamp or milliseconds from now (for queue waiting time limit) - * @param {number} [options.delay] - DEPRECATED: Use postDelay instead. Delay after task completion before next task of same priority + * @param {number} [options.postDelay] - Delay after this task completes before the next task can start. Overrides the priority's postDelay; use -1 to bypass the current delay period + * @param {number} [options.delay] - DEPRECATED: Use postDelay instead * @param {boolean} [options.bypassDelay] - If true, skip any active delay period and start immediately * @param {string} [options.coalescingKey] - Key for task coalescing - tasks with same key will be coalesced within windows * @param {number} [options.coalescingWindowDuration] - Override coalescing window duration (task-level override of key-level and defaults) @@ -137,8 +140,8 @@ export declare class HoldMyTask extends EventEmitter { * // Bypass current delay for urgent task * const urgent = queue.enqueue(urgentTask, { priority: 10, bypassDelay: true }); * - * // Alternative: use delay: -1 to bypass - * const urgent2 = queue.enqueue(urgentTask, { priority: 10, delay: -1 }); + * // Alternative: use postDelay: -1 to bypass + * const urgent2 = queue.enqueue(urgentTask, { priority: 10, postDelay: -1 }); * * // Coalescing tasks - multiple device status checks become one * queue.enqueue(checkDeviceStatus, callback1, { coalescingKey: "device-123", coalescingWindowDuration: 1000 }); @@ -151,10 +154,12 @@ export declare class HoldMyTask extends EventEmitter { id?: string | number; priority?: number; timestamp?: number; + startDelay?: number; start?: number; signal?: AbortSignal; timeout?: number; expire?: number; + postDelay?: number; delay?: number; bypassDelay?: boolean; coalescingKey?: string; @@ -428,7 +433,7 @@ export declare class HoldMyTask extends EventEmitter { * // Check with task-level overrides * const effectiveConfig = queue.getCoalescingConfig('ui.update', { * coalescingWindowDuration: 50, - * delay: 30 // Still accepts old property names for backwards compatibility + * postDelay: 30 // Deprecated task-level name delay is still accepted * }); */ getCoalescingConfig(coalescingKey: string, taskOptions?: Object): Object; @@ -488,8 +493,8 @@ export declare class HoldMyTask extends EventEmitter { * * // Check with task-level overrides * const effectiveConfig = queue.getPriorityConfig(1, { - * delay: 50, - * start: 10 + * postDelay: 50, + * startDelay: 10 // Deprecated task-level names delay/start are still accepted * }); */ getPriorityConfig(priority: number, taskOptions?: Object): Object; diff --git a/types/src/hold-my-task.d.mts.map b/types/src/hold-my-task.d.mts.map index a191e06..53ead91 100644 --- a/types/src/hold-my-task.d.mts.map +++ b/types/src/hold-my-task.d.mts.map @@ -1 +1 @@ -{"version":3,"file":"hold-my-task.d.mts","sourceRoot":"","sources":["../../src/hold-my-task.mjs"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"hold-my-task.d.mts","sourceRoot":"","sources":["../../src/hold-my-task.mjs"],"names":[],"mappings":"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"} \ No newline at end of file