Repository: graphile/worker

Issue:
Allow task to be in nested folder for crontab configurations.
### Feature description

Allow task to be in nested folder for crontab configurations.

Not works
> 0 8 * * * folder/task ?jobKey=jobKey

Works
> 0 8 * * * folder-task ?jobKey=jobKey


### Motivating example

We do have a simple `graphile.crontab` file, however for better readability we want our tasks to be in nested folders, it would be increasing our DX if we can declere cron tasks under nested folder inside tasks.


- [x] am interested in building this feature myself
- [x] am interested in collaborating on building this feature
- [x] am willing to help testing this feature before it's released
- [x] am willing to write a test-driven test suite for this feature (before it exists)
- [ ] am a [Graphile sponsor](https://www.graphile.org/sponsor/) ❤️
- [ ] have an active [support or consultancy contract](https://www.graphile.org/support/) with Graphile


Hints from issue comments:
@benjie: Seems reasonable 👍 @seapoint-doruk: > Seems reasonable 👍

Thanks @benjie created this pull request #543 @benjie: This will be going out in the next release 🚀 

Solution patch:
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 5298ad7..f0b7cbb 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -44,6 +44,8 @@ Read more:
 - `helpers.abortPromise` added; will reject when `abortSignal` aborts (useful
   for `Promise.race()`)
 - `backfillPeriod` is now marked as optional in TypeScript (defaults to 0).
+- Support for loading tasks from nested folders in crontab.
+  - (`* * * * * nested/folder/task ?jobKey=my_key&jobKeyMode=preserve_run_at`)
 
 ## v0.16.6
 
diff --git a/src/cronConstants.ts b/src/cronConstants.ts
index f4b73a5..6d563c8 100644
--- a/src/cronConstants.ts
+++ b/src/cronConstants.ts
@@ -28,7 +28,7 @@ export const CRONTAB_WILDCARD = /^\*(?:\/([0-9]+))?$/;
 // The command from the crontab line
 /** Splits the command from the crontab line into the task, options and payload. */
 export const CRONTAB_COMMAND =
-  /^([_a-zA-Z][_a-zA-Z0-9:_-]*)(?:\s+\?([^\s]+))?(?:\s+(\{.*\}))?$/;
+  /^([_a-zA-Z][_a-zA-Z0-9:/_-]*)(?:\s+\?([^\s]+))?(?:\s+(\{.*\}))?$/;
 
 // Crontab command options
 /** Matches the id=UID option, capturing the unique identifier */
diff --git a/yarn.lock b/yarn.lock
index 617fe16..0a2c7a4 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6768,9 +6768,9 @@ http-proxy-agent@^4.0.1:
     debug "4"
 
 http-proxy-middleware@^2.0.3:
-  version "2.0.7"
-  resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz#915f236d92ae98ef48278a95dedf17e991936ec6"
-  integrity sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA==
+  version "2.0.9"
+  resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz#e9e63d68afaa4eee3d147f39149ab84c0c2815ef"
+  integrity sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==
   dependencies:
     "@types/http-proxy" "^1.17.8"
     http-proxy "^1.18.1"



Test patch:
diff --git a/__tests__/__snapshots__/crontab.test.ts.snap b/__tests__/__snapshots__/crontab.test.ts.snap
index 7181a4f..023d111 100644
--- a/__tests__/__snapshots__/crontab.test.ts.snap
+++ b/__tests__/__snapshots__/crontab.test.ts.snap
@@ -143,5 +143,35 @@ Array [
     "task": "with_key_and_mode",
     Symbol(isParsed): true,
   },
+  Object {
+    "identifier": "nested/task",
+    "match": [Function],
+    "options": Object {
+      "backfillPeriod": 0,
+      "jobKey": "my_key",
+      "jobKeyMode": "preserve_run_at",
+      "maxAttempts": undefined,
+      "priority": undefined,
+      "queueName": undefined,
+    },
+    "payload": null,
+    "task": "nested/task",
+    Symbol(isParsed): true,
+  },
+  Object {
+    "identifier": "nested/folder/task",
+    "match": [Function],
+    "options": Object {
+      "backfillPeriod": 0,
+      "jobKey": "my_key",
+      "jobKeyMode": "preserve_run_at",
+      "maxAttempts": undefined,
+      "priority": undefined,
+      "queueName": undefined,
+    },
+    "payload": null,
+    "task": "nested/folder/task",
+    Symbol(isParsed): true,
+  },
 ]
 `;
diff --git a/__tests__/crontab.test.ts b/__tests__/crontab.test.ts
index afb8ac3..8dd2dd0 100644
--- a/__tests__/crontab.test.ts
+++ b/__tests__/crontab.test.ts
@@ -44,6 +44,8 @@ test("parses crontab file correctly", () => {
     *     *      *       *       *      lots_of_spaces     
 * * * * * with_key ?jobKey=my_key
 * * * * * with_key_and_mode ?jobKey=my_key&jobKeyMode=preserve_run_at
+* * * * * nested/task ?jobKey=my_key&jobKeyMode=preserve_run_at
+* * * * * nested/folder/task ?jobKey=my_key&jobKeyMode=preserve_run_at
 `;
   const parsed = parseCrontab(exampleCrontab);
 
@@ -173,6 +175,20 @@ test("parses crontab file correctly", () => {
   });
   expect(parsed[8].payload).toEqual(null);
 
+  expect(parsed[9].task).toEqual("nested/task");
+  expect(parsed[9].identifier).toEqual("nested/task");
+  const parsedCronMatch9 = (parsed[9].match as any)
+    .parsedCronMatch as ParsedCronMatch;
+  expect(parsedCronMatch9.minutes).toEqual(ALL_MINUTES);
+  expect(parsedCronMatch9.hours).toEqual(ALL_HOURS);
+
+  expect(parsed[10].task).toEqual("nested/folder/task");
+  expect(parsed[10].identifier).toEqual("nested/folder/task");
+  const parsedCronMatch10 = (parsed[10].match as any)
+    .parsedCronMatch as ParsedCronMatch;
+  expect(parsedCronMatch10.minutes).toEqual(ALL_MINUTES);
+  expect(parsedCronMatch10.hours).toEqual(ALL_HOURS);
+
   expect(parsed).toMatchSnapshot();
 });