Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
6a81d7b
fix(ci): derive release base in never-supersede concurrency (not hard…
Shinrai Aug 4, 2026
519fdc5
fix(ci): never supersede release-relevant CI runs so release PRs stay…
Shinrai Aug 4, 2026
7d3565b
chore: bump version to 1.6.3
cldmv-bot[bot] Aug 4, 2026
ac2ec87
test(ci): isolate flaky bypassDelay completion-delay test into its ow…
Shinrai Aug 4, 2026
de48ac6
fix: prevent scheduler deadlock when a ready task's delay expires mid…
Shinrai Aug 4, 2026
471bd50
fix(review): only force imminent recheck when nextTime would stay Inf…
Shinrai Aug 4, 2026
96b6bf0
deps: bump prettier from 3.6.2 to 3.9.6 in the minor group
dependabot[bot] Aug 5, 2026
5737d19
deps: bump @types/node from 20.19.24 to 26.1.2
dependabot[bot] Aug 5, 2026
275bf15
deps: bump prettier from 3.6.2 to 3.9.6 in the minor group (#9)
cldmv-bot[bot] Aug 5, 2026
cbc1ac7
fix: make HoldMyTask and its constructor aliases real classes
Shinrai Aug 7, 2026
964088d
test: add regression test for the scheduler stranded-task deadlock
Shinrai Aug 8, 2026
01d22d5
fix(types): type createHoldMyTask/createQueue/etc. as returning HoldM…
Shinrai Aug 8, 2026
eb6d14f
fix!: mark HoldMyTask default/alias export change as breaking
Shinrai Aug 8, 2026
2240dfd
fix: namespace dev condition to holdmytask-dev; correct + test devcheck
Shinrai Aug 8, 2026
c6427b1
refactor(devcheck): align to the @cldmv/uuid reference pattern
Shinrai Aug 8, 2026
d2c6896
fix(devcheck): keep condition-only trigger + detect execArgv; drop uu…
Shinrai Aug 8, 2026
2af4c55
fix(review): exact --conditions match, drop NODE_ENV override, keep m…
Shinrai Aug 8, 2026
f3f39ef
fix: remove vestigial ./devcheck export (points at unshipped file)
Shinrai Aug 8, 2026
85afcf0
test(review): restore setTimeout spy in finally, capture calls before…
Shinrai Aug 8, 2026
1a2b6f3
docs(review): correct the devcheck ordering comment in index.mjs
Shinrai Aug 8, 2026
979ea9c
fix!: make HoldMyTask and its constructor aliases real classes (#11)
Shinrai Aug 8, 2026
65cf83a
chore: bump version to 2.0.0
cldmv-bot[bot] Aug 9, 2026
a959151
fix(review): parse --conditions as whole literal values (no comma/pip…
Shinrai Aug 9, 2026
ff6c70f
test(review): fail fast + always clean up if drain never fires
Shinrai Aug 9, 2026
cc37aa2
docs(review): drop stale test.env reference in vitest config comment
Shinrai Aug 9, 2026
a456202
test(review): poll for the observable state instead of a fixed 120ms …
Shinrai Aug 9, 2026
8a22726
fix: prevent scheduler deadlock when a ready task's delay expires mid…
Shinrai Aug 9, 2026
4f96129
fix(review): advance scanner past consumed --conditions value; drop i…
Shinrai Aug 9, 2026
ee9235b
docs(review): soften devcheck message - don't assert it's "loading fr…
Shinrai Aug 9, 2026
ab14d3a
deps: bump @types/node from 20.19.24 to 26.1.2 (#10)
Shinrai Aug 9, 2026
6ed70af
fix: namespace dev condition to holdmytask-dev; correct + test devche…
Shinrai Aug 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .configs/vitest.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,37 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");

export default defineConfig({
root,
// The package-scoped dev condition that routes `@cldmv/holdmytask/main` to `src/`
// (see the `./main` export in package.json). Tests exercise and cover the SOURCE
// tree, so the resolver must add `holdmytask-dev`. This *replaces* vite's default
// conditions, so the usual ones are kept alongside it. `test.nodeOptions` below
// carries the same condition into forked test workers (for native imports of the
// package entry, e.g. CommonAliases importing index.mjs -> /main), so a bare local
// `npm test` resolves to src the same way CI does. Mirrors @cldmv/uuid.
resolve: {
conditions: ["holdmytask-dev", "module", "browser", "development|production"]
},
ssr: {
// Vitest often routes node-environment resolution through the SSR pipeline. Keep
// `module` here alongside the non-SSR resolver so a dependency's `module`-keyed
// export resolves the same under Vitest's SSR pipeline as in a normal build.
resolve: {
conditions: ["holdmytask-dev", "module", "node", "development|production"]
}
},
test: {
// Fleet-wide vitest test-file convention: `*.test.vitest.mjs`.
include: ["tests/**/*.test.vitest.mjs"],
exclude: ["node_modules"],
environment: "node",
globals: true,
testTimeout: 30000,
// Carry the dev condition into forked workers (native imports of the package
// entry, e.g. CommonAliases importing index.mjs -> /main). NODE_ENV is left
// alone: it does not select the conditional export (that's `--conditions`), and
// forcing a non-standard `NODE_ENV=holdmytask-dev` could confuse deps that key
// off the usual test/development/production values.
nodeOptions: ["--conditions=holdmytask-dev"],
// "dot" keeps CI logs to one character per test file instead of a full
// "RUN vX.Y.Z" + per-file pass/fail block for every file β€” vitest's
// non-interactive fallback (no TTY to redraw) otherwise reprints that
Expand Down
27 changes: 20 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -131,14 +131,27 @@ on:
required: false
default: true

# Cancel superseded runs on feature branches; keep every master/main run as the
# permanent green record. Keyed on github.ref so push and pull_request events
# for the same branch share a group (the `if:` on the ci job already prevents
# non-fork PR sync from running, but the shared group guards against edge
# cases).
# Concurrency policy, by context:
# - FEATURE branches / feature PRs β†’ cancel superseded runs (per-ref group +
# cancel-in-progress): a newer push makes the older run redundant.
# - RELEASE-relevant contexts β†’ NEVER superseded. Pushes to the release base
# branch (derived: the CLDMV_RELEASE_BASE var β†’ the repo's default branch),
# to next/hotfixes, and the next/hotfixes β†’ base release PRs each get a
# UNIQUE group per run (run_id appended), so nothing cancels them. During
# the burst of pushes a release makes to next/hotfixes (the feature squash,
# the post-hotfix base→next sync merge, the bot's `chore: bump version`),
# every run completes and posts a GREEN check instead of the earlier one
# being cancelled into a red X on the release PR. A bare
# `cancel-in-progress: false` is NOT enough β€” GitHub still cancels the
# middle PENDING run when a newer one queues; a unique group avoids it.
# The base is NOT hardcoded to master/main β€” it derives the same way
# resolve-release-base does (CLDMV_RELEASE_BASE override β†’ default_branch).
# next/hotfixes are the flow's fixed integration-branch names. github.head_ref
# is set only on pull_request (the release PR's head β†’ next/hotfixes);
# github.ref carries the branch on push.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/master' && github.ref != 'refs/heads/main' }}
group: ci-${{ github.workflow }}-${{ github.ref }}${{ (github.ref == format('refs/heads/{0}', vars.CLDMV_RELEASE_BASE != '' && vars.CLDMV_RELEASE_BASE || github.event.repository.default_branch) || github.ref == 'refs/heads/next' || github.ref == 'refs/heads/hotfixes' || github.head_ref == 'next' || github.head_ref == 'hotfixes') && format('-{0}', github.run_id) || '' }}
cancel-in-progress: true

# Workflow-level: matches the broadest write surface the called
# `workflow-ci.yml` reaches across its branches:
Expand Down
77 changes: 61 additions & 16 deletions devcheck.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* @Last modified by: Nate Hyson <CLDMV> (Shinrai@users.noreply.github.com)
* @Last modified time: 2025-11-21 14:51:16 -08:00 (1763765476)
* -----
* @Copyright: Copyright (c) 2013-2025 Catalyzed Motivation Inc. All rights reserved.
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
*/

import { existsSync } from "node:fs";
Expand All @@ -18,7 +18,6 @@ import path from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const srcPath = path.join(__dirname, "src");
// const distPath = path.join(__dirname, "dist");

// Detect if we're running in a CI environment
const isCI = !!(
Expand All @@ -32,29 +31,75 @@ const isCI = !!(
process.env.TF_BUILD // Azure DevOps
);

if (existsSync(srcPath) && !isCI) {
// if (existsSync(srcPath) && !existsSync(distPath)) {
const nodeEnv = process.env.NODE_ENV?.toLowerCase();
const hasNodeOptions = process.env.NODE_OPTIONS?.includes("--conditions=development");
// Skip when installed as a dependency (a `node_modules` segment anywhere above this
// file - covers scoped `node_modules/@cldmv/holdmytask` and unscoped installs). The
// npm-published package ships neither `src/` nor this file, so this branch is already
// moot there; but a git/tarball install DOES include them, and without this guard
// devcheck would `process.exit(1)` inside a consumer's app. A "parent dir ===
// node_modules" check would miss scoped packages (parent is the scope dir).
const isInstalledPackage = __dirname.split(path.sep).includes("node_modules");

if (!nodeEnv || (!["dev", "development"].includes(nodeEnv) && !hasNodeOptions)) {
// Only meaningful in a source checkout. When `src/` is present the developer should be
// loading from it via the `holdmytask-dev` condition; if that condition isn't set they
// are silently running the built `dist/` copy instead, so warn - even after a build,
// since a built checkout has BOTH src/ and dist/ and the condition is the only thing
// that selects src/.
if (existsSync(srcPath) && !isCI && !isInstalledPackage) {
// The condition selects src/ (see the `./main` export in package.json). It can be
// supplied via NODE_OPTIONS (`NODE_OPTIONS=--conditions=holdmytask-dev`) OR directly
// on the node CLI (`node --conditions=holdmytask-dev` / `-C holdmytask-dev`), which
// lands in execArgv - this is how vitest passes it to workers - so scan both. Each
// `--conditions` occurrence is ONE literal condition value: Node does not split it on
// `,` or `|` (verified - `--conditions=holdmytask-dev,x` and
// `--conditions=holdmytask-dev|x` do NOT enable `holdmytask-dev`), and multiple
// conditions are passed as repeated flags. So collect each value whole and match
// EXACTLY - no substring, no splitting - so `not-holdmytask-dev`, `holdmytask-dev,x`,
// and `holdmytask-dev|production` all correctly fail to count. Namespaced (not the
// generic `development`) so a consuming app's own `--conditions=development` can't
// flip this package to a source tree it doesn't ship. NODE_ENV is deliberately NOT
// consulted: it does not affect which tree resolves.
const conditions = [];
const scan = (tokens) => {
for (let i = 0; i < tokens.length; i++) {
if (tokens[i] === "--conditions" || tokens[i] === "-C") {
// Space form (`--conditions x` / `-C x`): consume the following token as this
// flag's value and SKIP it, so a value that itself looks like a flag (e.g. the
// literal `--conditions=x`) isn't re-interpreted on the next iteration.
if (tokens[i + 1] !== undefined) {
conditions.push(tokens[i + 1]);
i++;
}
} else if (tokens[i].startsWith("--conditions=")) {
conditions.push(tokens[i].slice("--conditions=".length));
}
// Note: `-C=x` is intentionally not handled - Node rejects it ("bad option"),
// so it can never appear in execArgv/NODE_OPTIONS. Valid forms are
// `--conditions=x`, `--conditions x`, and `-C x`.
}
};
scan(process.execArgv);
scan((process.env.NODE_OPTIONS || "").split(/\s+/).filter(Boolean));
const hasHoldMyTaskDev = conditions.includes("holdmytask-dev");

if (!hasHoldMyTaskDev) {
console.error("❌ Development environment not properly configured!");
console.error("πŸ“ Source folder detected but NODE_ENV/NODE_OPTIONS not set for development.");
console.error("πŸ“ Source folder detected but the 'holdmytask-dev' condition is not set,");
console.error(" so imports resolve to dist/ by default (or fail if it isn't built) instead of src/.");
console.error("");
console.error("πŸ”§ To fix this, run one of these commands:");
console.error("πŸ”§ To load from src/ for development, set the condition:");
console.error(" Windows (cmd):");
console.error(" set NODE_ENV=development");
console.error(" set NODE_OPTIONS=--conditions=development");
console.error(" set NODE_OPTIONS=--conditions=holdmytask-dev");
console.error("");
console.error(" Windows (PowerShell):");
console.error(" $env:NODE_ENV='development'");
console.error(" $env:NODE_OPTIONS='--conditions=development'");
console.error(" $env:NODE_OPTIONS='--conditions=holdmytask-dev'");
console.error("");
console.error(" Unix/Linux/macOS:");
console.error(" export NODE_ENV=development");
console.error(" export NODE_OPTIONS=--conditions=development");
console.error(" export NODE_OPTIONS=--conditions=holdmytask-dev");
console.error("");
console.error(" ...or pass it directly: node --conditions=holdmytask-dev <file>");
console.error("");
console.error("πŸ’‘ This ensures this module loads from src/ instead of dist/ for development.");
console.error("πŸ’‘ 'holdmytask-dev' is namespaced so it can't conflict with a consumer's");
console.error(" own development conditions.");
console.error("πŸš€ CI environments automatically skip this check.");
process.exit(1);
}
Expand Down
45 changes: 22 additions & 23 deletions index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@
* @Copyright: Copyright (c) 2013-2025 Catalyzed Motivation Inc. All rights reserved.
*/

// Development environment check (must happen before holdmytask imports)
// Development environment check. NOTE: the static `import` of the core below is
// hoisted and evaluated before this IIFE body runs, so devcheck does NOT run before
// the core loads - it's a best-effort, fire-and-forget dev-time warning. (Running it
// strictly first would require a dynamic import + top-level await, which breaks the
// index.cjs bridge's synchronous `require` of this module - see PR #11 discussion.)
(async () => {
try {
await import("./devcheck.mjs");
Expand All @@ -20,56 +24,51 @@
}
})();

import { HoldMyTask } from "@cldmv/holdmytask/main";

/**
* Creates a HoldMyTask instance for task queue management
* @param {object} [options={}] - Configuration options
* @returns {Promise<object>} HoldMyTask instance
* @returns {Promise<HoldMyTask>} HoldMyTask instance
*/
export default async function createHoldMyTask(options = {}) {
// Dynamic import after environment check
const mod = await import("@cldmv/holdmytask/main");
const HoldMyTask = mod.HoldMyTask;
export async function createHoldMyTask(options = {}) {
return new HoldMyTask(options);
}

/**
* Create a task queue instance
* @param {object} [options={}] - Configuration options
* @returns {Promise<object>} HoldMyTask instance
* @returns {Promise<HoldMyTask>} HoldMyTask instance
*/
export async function createQueue(options = {}) {
const mod = await import("@cldmv/holdmytask/main");
const HoldMyTask = mod.HoldMyTask;
return new HoldMyTask(options);
}

/**
* Create a task manager instance
* @param {object} [options={}] - Configuration options
* @returns {Promise<object>} HoldMyTask instance
* @returns {Promise<HoldMyTask>} HoldMyTask instance
*/
export async function createTaskManager(options = {}) {
const mod = await import("@cldmv/holdmytask/main");
const HoldMyTask = mod.HoldMyTask;
return new HoldMyTask(options);
}

/**
* Create a task processor instance
* @param {object} [options={}] - Configuration options
* @returns {Promise<object>} HoldMyTask instance
* @returns {Promise<HoldMyTask>} HoldMyTask instance
*/
export async function createTaskProcessor(options = {}) {
const mod = await import("@cldmv/holdmytask/main");
const HoldMyTask = mod.HoldMyTask;
return new HoldMyTask(options);
}

// Named export aliases
export { createHoldMyTask as HoldMyTask };
export { createQueue as queue };
export { createQueue as Queue };
export { createTaskManager as TaskManager };
export { createQueue as TaskQueue };
export { createQueue as QueueManager };
export { createTaskProcessor as TaskProcessor };
// HoldMyTask and its constructor aliases are the real class (see issue #3) - `new
// HoldMyTask()`, `new QueueManager()`, etc. all construct the same underlying type.
export { HoldMyTask };
export default HoldMyTask;
export { HoldMyTask as queue };
export { HoldMyTask as Queue };
export { HoldMyTask as TaskManager };
export { HoldMyTask as TaskQueue };
export { HoldMyTask as QueueManager };
export { HoldMyTask as TaskProcessor };
26 changes: 13 additions & 13 deletions package-lock.json

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

10 changes: 3 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cldmv/holdmytask",
"version": "1.6.2",
"version": "2.0.0",
"description": "A tiny task queue that waits until your task is ready",
"main": "./index.cjs",
"module": "./index.mjs",
Expand All @@ -11,12 +11,8 @@
"import": "./index.mjs",
"require": "./index.cjs"
},
"./devcheck": {
"types": "./types/devcheck.d.mts",
"import": "./devcheck.mjs"
},
"./main": {
"development": {
"holdmytask-dev": {
"types": "./types/src/hold-my-task.d.mts",
"import": "./src/hold-my-task.mjs"
},
Expand Down Expand Up @@ -91,7 +87,7 @@
"@eslint/markdown": "^8.0.3",
"@html-eslint/eslint-plugin": "^0.64.0",
"@html-eslint/parser": "^0.64.0",
"@types/node": "^20.0.0",
"@types/node": "^26.1.2",
"@vitest/coverage-v8": "^4.1.10",
"@vitest/ui": "^4.1.10",
"eslint": "^10.8.0",
Expand Down
10 changes: 10 additions & 0 deletions src/hold-my-task.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1622,6 +1622,16 @@ export class HoldMyTask extends EventEmitter {
nextTime = Math.min(nextTime, this.nextAvailableTime);
}

// A task already sitting in the ready heap needs an imminent recheck if nothing
// else would schedule one - otherwise nextTime falls through to Infinity and the
// 24.8-day fallback below strands it. Only force this when nextTime would
// otherwise stay Infinity (the stranded-task case); an active future delay
// already set nextTime above and should keep its single efficient timeout
// rather than degrade into a tick-interval poll loop until it expires.
if (nextTime === Infinity && this.readyHeap.size() > 0) {
nextTime = now;
}

// If next event is imminent or past, run immediately
if (nextTime <= now + this.options.tick) {
this.intervalId = setInterval(() => this.schedulerTick(), this.options.tick);
Expand Down
Loading
Loading