@stonyx/cron 0.2.1-alpha.0 → 0.2.1-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/architecture.md +215 -0
- package/.claude/extension-guide.md +291 -0
- package/.claude/improvements.md +53 -0
- package/.claude/project-structure.md +29 -661
- package/.claude/testing.md +85 -0
- package/.github/workflows/ci.yml +5 -25
- package/.github/workflows/publish.yml +24 -116
- package/.npmignore +3 -1
- package/README.md +1 -1
- package/logs/error.log +2 -0
- package/package.json +12 -5
- package/pnpm-lock.yaml +11 -10
- package/src/cron-parser.js +246 -0
- package/src/job.js +200 -0
- package/src/locked.js +34 -0
- package/src/normalize.js +163 -0
- package/src/run-log.js +79 -0
- package/src/schedule.js +81 -0
- package/src/service.js +303 -0
- package/.git/config +0 -18
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Testing Guidelines
|
|
2
|
+
|
|
3
|
+
## Testing Guidelines
|
|
4
|
+
|
|
5
|
+
### Test Structure
|
|
6
|
+
Tests are located in `stonyx-cron/test/unit/` and use QUnit modules:
|
|
7
|
+
|
|
8
|
+
```javascript
|
|
9
|
+
import QUnit from 'qunit';
|
|
10
|
+
import sinon from 'sinon';
|
|
11
|
+
import { setupIntegrationTests } from "stonyx/test-helpers";
|
|
12
|
+
|
|
13
|
+
const { module, test } = QUnit;
|
|
14
|
+
|
|
15
|
+
module('[Unit] Cron', function (hooks) {
|
|
16
|
+
setupIntegrationTests(hooks);
|
|
17
|
+
|
|
18
|
+
let cron, clock;
|
|
19
|
+
|
|
20
|
+
hooks.beforeEach(function () {
|
|
21
|
+
clock = sinon.useFakeTimers({ shouldAdvanceTime: false });
|
|
22
|
+
cron = new Cron();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
hooks.afterEach(function () {
|
|
26
|
+
sinon.restore();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('test description', async function (assert) {
|
|
30
|
+
// Test implementation
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Fake Timers Pattern (CRITICAL)
|
|
36
|
+
**Always use fake timers for time-based tests:**
|
|
37
|
+
|
|
38
|
+
```javascript
|
|
39
|
+
// Setup in beforeEach
|
|
40
|
+
clock = sinon.useFakeTimers({ shouldAdvanceTime: false });
|
|
41
|
+
|
|
42
|
+
// Advance time synchronously
|
|
43
|
+
clock.tick(5000); // Advance 5 seconds
|
|
44
|
+
|
|
45
|
+
// For async operations, use tickAsync
|
|
46
|
+
clock.tick(5000);
|
|
47
|
+
await clock.tickAsync(0); // Process async callbacks
|
|
48
|
+
|
|
49
|
+
// Cleanup in afterEach
|
|
50
|
+
sinon.restore();
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**Why `shouldAdvanceTime: false`?**
|
|
54
|
+
Prevents real time from interfering with fake time, ensuring deterministic tests.
|
|
55
|
+
|
|
56
|
+
### Spies & Stubs Patterns
|
|
57
|
+
```javascript
|
|
58
|
+
// Spy on function calls
|
|
59
|
+
const cb = sinon.spy();
|
|
60
|
+
cron.register('job1', cb, 5);
|
|
61
|
+
assert.ok(cb.calledOnce, 'Callback executed once');
|
|
62
|
+
|
|
63
|
+
// Stub methods
|
|
64
|
+
const stub = sinon.stub().rejects(new Error('boom'));
|
|
65
|
+
cron.register('jobErr', stub, 1);
|
|
66
|
+
|
|
67
|
+
// Spy on existing methods
|
|
68
|
+
const logSpy = sinon.spy(log, 'cron');
|
|
69
|
+
cron.log('test message');
|
|
70
|
+
assert.ok(logSpy.calledOnce, 'Log called');
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Test Coverage Expectations
|
|
74
|
+
Tests should cover:
|
|
75
|
+
- Job registration and execution
|
|
76
|
+
- Rescheduling behavior
|
|
77
|
+
- Unregistration
|
|
78
|
+
- Error handling
|
|
79
|
+
- Configuration-driven logging
|
|
80
|
+
- Edge cases (empty heap, multiple jobs, etc.)
|
|
81
|
+
|
|
82
|
+
### Running Tests
|
|
83
|
+
```bash
|
|
84
|
+
pnpm test # Runs: stonyx test
|
|
85
|
+
```
|
package/.github/workflows/ci.yml
CHANGED
|
@@ -2,35 +2,15 @@ name: CI
|
|
|
2
2
|
|
|
3
3
|
on:
|
|
4
4
|
pull_request:
|
|
5
|
-
branches:
|
|
6
|
-
- dev
|
|
7
|
-
- main
|
|
5
|
+
branches: [dev, main]
|
|
8
6
|
|
|
9
7
|
concurrency:
|
|
10
8
|
group: ci-${{ github.head_ref || github.ref }}
|
|
11
9
|
cancel-in-progress: true
|
|
12
10
|
|
|
11
|
+
permissions:
|
|
12
|
+
contents: read
|
|
13
|
+
|
|
13
14
|
jobs:
|
|
14
15
|
test:
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
steps:
|
|
18
|
-
- name: Checkout code
|
|
19
|
-
uses: actions/checkout@v3
|
|
20
|
-
|
|
21
|
-
- name: Setup pnpm
|
|
22
|
-
uses: pnpm/action-setup@v4
|
|
23
|
-
with:
|
|
24
|
-
version: 9
|
|
25
|
-
|
|
26
|
-
- name: Set up Node.js
|
|
27
|
-
uses: actions/setup-node@v3
|
|
28
|
-
with:
|
|
29
|
-
node-version: 24.13.0
|
|
30
|
-
cache: 'pnpm'
|
|
31
|
-
|
|
32
|
-
- name: Install dependencies
|
|
33
|
-
run: pnpm install --frozen-lockfile
|
|
34
|
-
|
|
35
|
-
- name: Run tests
|
|
36
|
-
run: pnpm test
|
|
16
|
+
uses: abofs/stonyx-workflows/.github/workflows/ci.yml@main
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
name: Publish to NPM
|
|
2
2
|
|
|
3
3
|
on:
|
|
4
|
-
|
|
4
|
+
repository_dispatch:
|
|
5
|
+
types: [cascade-publish]
|
|
5
6
|
workflow_dispatch:
|
|
6
7
|
inputs:
|
|
7
8
|
version-type:
|
|
@@ -9,7 +10,6 @@ on:
|
|
|
9
10
|
required: true
|
|
10
11
|
type: choice
|
|
11
12
|
options:
|
|
12
|
-
- alpha
|
|
13
13
|
- patch
|
|
14
14
|
- minor
|
|
15
15
|
- major
|
|
@@ -17,127 +17,35 @@ on:
|
|
|
17
17
|
description: 'Custom version (optional, overrides version-type)'
|
|
18
18
|
required: false
|
|
19
19
|
type: string
|
|
20
|
-
|
|
21
|
-
# Auto-publish alpha on PR
|
|
22
20
|
pull_request:
|
|
23
21
|
types: [opened, synchronize, reopened]
|
|
24
|
-
branches: [main
|
|
25
|
-
|
|
26
|
-
# Auto-publish stable on merge to main
|
|
22
|
+
branches: [main]
|
|
27
23
|
push:
|
|
28
24
|
branches: [main]
|
|
29
25
|
|
|
26
|
+
concurrency:
|
|
27
|
+
group: ${{ github.event_name == 'repository_dispatch' && 'cascade-update' || format('publish-{0}', github.ref) }}
|
|
28
|
+
cancel-in-progress: false
|
|
29
|
+
|
|
30
30
|
permissions:
|
|
31
31
|
contents: write
|
|
32
|
-
id-token: write
|
|
33
|
-
pull-requests: write
|
|
32
|
+
id-token: write
|
|
33
|
+
pull-requests: write
|
|
34
34
|
|
|
35
35
|
jobs:
|
|
36
36
|
publish:
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
- name: Set up Node.js
|
|
53
|
-
uses: actions/setup-node@v3
|
|
54
|
-
with:
|
|
55
|
-
node-version: 24.13.0
|
|
56
|
-
cache: 'pnpm'
|
|
57
|
-
registry-url: 'https://registry.npmjs.org'
|
|
58
|
-
|
|
59
|
-
- name: Install dependencies
|
|
60
|
-
run: pnpm install --frozen-lockfile
|
|
61
|
-
|
|
62
|
-
- name: Run tests
|
|
63
|
-
run: pnpm test
|
|
64
|
-
|
|
65
|
-
- name: Configure git
|
|
66
|
-
run: |
|
|
67
|
-
git config user.name "github-actions[bot]"
|
|
68
|
-
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
69
|
-
|
|
70
|
-
# Determine version type based on trigger
|
|
71
|
-
- name: Determine version bump type
|
|
72
|
-
id: version-type
|
|
73
|
-
run: |
|
|
74
|
-
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
|
75
|
-
echo "type=alpha" >> $GITHUB_OUTPUT
|
|
76
|
-
elif [ "${{ github.event_name }}" = "push" ]; then
|
|
77
|
-
echo "type=patch" >> $GITHUB_OUTPUT
|
|
78
|
-
elif [ "${{ github.event.inputs.custom-version }}" != "" ]; then
|
|
79
|
-
echo "type=custom" >> $GITHUB_OUTPUT
|
|
80
|
-
else
|
|
81
|
-
echo "type=${{ github.event.inputs.version-type }}" >> $GITHUB_OUTPUT
|
|
82
|
-
fi
|
|
83
|
-
|
|
84
|
-
# Version bumping
|
|
85
|
-
- name: Bump version (custom)
|
|
86
|
-
if: steps.version-type.outputs.type == 'custom'
|
|
87
|
-
run: pnpm version ${{ github.event.inputs.custom-version }} --no-git-tag-version
|
|
88
|
-
|
|
89
|
-
- name: Bump version (alpha)
|
|
90
|
-
if: steps.version-type.outputs.type == 'alpha'
|
|
91
|
-
run: pnpm version prerelease --preid=alpha --no-git-tag-version
|
|
92
|
-
|
|
93
|
-
- name: Bump version (patch/minor/major)
|
|
94
|
-
if: steps.version-type.outputs.type == 'patch' || steps.version-type.outputs.type == 'minor' || steps.version-type.outputs.type == 'major'
|
|
95
|
-
run: pnpm version ${{ steps.version-type.outputs.type }} --no-git-tag-version
|
|
96
|
-
|
|
97
|
-
- name: Get package version
|
|
98
|
-
id: package-version
|
|
99
|
-
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
|
100
|
-
|
|
101
|
-
# Publishing
|
|
102
|
-
- name: Publish to NPM (alpha)
|
|
103
|
-
if: contains(steps.package-version.outputs.version, 'alpha')
|
|
104
|
-
run: pnpm publish --tag alpha --access public --no-git-checks
|
|
105
|
-
|
|
106
|
-
- name: Publish to NPM (stable)
|
|
107
|
-
if: "!contains(steps.package-version.outputs.version, 'alpha')"
|
|
108
|
-
run: pnpm publish --access public
|
|
109
|
-
|
|
110
|
-
# Only commit and tag for stable releases (push to main or manual stable)
|
|
111
|
-
- name: Commit version bump and create tag
|
|
112
|
-
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && !contains(steps.package-version.outputs.version, 'alpha'))
|
|
113
|
-
run: |
|
|
114
|
-
git add package.json
|
|
115
|
-
git commit -m "chore: release v${{ steps.package-version.outputs.version }}"
|
|
116
|
-
git tag v${{ steps.package-version.outputs.version }}
|
|
117
|
-
git push origin main --tags
|
|
118
|
-
|
|
119
|
-
- name: Create GitHub Release
|
|
120
|
-
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && !contains(steps.package-version.outputs.version, 'alpha'))
|
|
121
|
-
uses: actions/create-release@v1
|
|
122
|
-
env:
|
|
123
|
-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
124
|
-
with:
|
|
125
|
-
tag_name: v${{ steps.package-version.outputs.version }}
|
|
126
|
-
release_name: v${{ steps.package-version.outputs.version }}
|
|
127
|
-
draft: false
|
|
128
|
-
prerelease: false
|
|
129
|
-
|
|
130
|
-
# Add PR comment with alpha version info
|
|
131
|
-
- name: Comment on PR with alpha version
|
|
132
|
-
if: github.event_name == 'pull_request'
|
|
133
|
-
uses: actions/github-script@v6
|
|
134
|
-
with:
|
|
135
|
-
script: |
|
|
136
|
-
const version = '${{ steps.package-version.outputs.version }}';
|
|
137
|
-
const packageName = require('./package.json').name;
|
|
138
|
-
github.rest.issues.createComment({
|
|
139
|
-
issue_number: context.issue.number,
|
|
140
|
-
owner: context.repo.owner,
|
|
141
|
-
repo: context.repo.repo,
|
|
142
|
-
body: `## 🚀 Alpha Version Published\n\n**Version:** \`${version}\`\n\n**Install:**\n\`\`\`bash\npnpm add ${packageName}@${version}\n# or\npnpm add ${packageName}@alpha # latest alpha\n\`\`\`\n\nThis alpha version is now available for testing!`
|
|
143
|
-
});
|
|
37
|
+
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
|
38
|
+
uses: abofs/stonyx-workflows/.github/workflows/npm-publish.yml@main
|
|
39
|
+
with:
|
|
40
|
+
version-type: ${{ github.event.inputs.version-type }}
|
|
41
|
+
custom-version: ${{ github.event.inputs.custom-version }}
|
|
42
|
+
cascade-source: ${{ github.event.client_payload.source_package || '' }}
|
|
43
|
+
secrets: inherit
|
|
44
|
+
|
|
45
|
+
cascade:
|
|
46
|
+
needs: publish
|
|
47
|
+
uses: abofs/stonyx-workflows/.github/workflows/cascade.yml@main
|
|
48
|
+
with:
|
|
49
|
+
package-name: ${{ needs.publish.outputs.package-name }}
|
|
50
|
+
published-version: ${{ needs.publish.outputs.published-version }}
|
|
51
|
+
secrets: inherit
|
package/.npmignore
CHANGED
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ When a job is executed, its next trigger time is updated, and it is re-inserted
|
|
|
31
31
|
| `register` | `key: string, callback: Function, interval: number, runOnInit?: boolean` | Register a new job with a given interval in seconds. If `runOnInit` is true, the job runs immediately upon registration. |
|
|
32
32
|
| `unregister` | `key: string` | Remove a previously registered job. |
|
|
33
33
|
|
|
34
|
-
>
|
|
34
|
+
> `MinHeap` is also exported as a public subpath (`@stonyx/cron/min-heap`) and can be imported directly for advanced usage.
|
|
35
35
|
|
|
36
36
|
## Configuration
|
|
37
37
|
|
package/logs/error.log
CHANGED
package/package.json
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
"keywords": [
|
|
4
4
|
"stonyx-module"
|
|
5
5
|
],
|
|
6
|
-
"version": "0.2.1-alpha.
|
|
7
|
-
"description": "",
|
|
6
|
+
"version": "0.2.1-alpha.2",
|
|
7
|
+
"description": "Cron/job scheduler for Stonyx framework",
|
|
8
8
|
"main": "src/main.js",
|
|
9
9
|
"type": "module",
|
|
10
10
|
"files": [
|
|
@@ -12,6 +12,13 @@
|
|
|
12
12
|
],
|
|
13
13
|
"exports": {
|
|
14
14
|
".": "./src/main.js",
|
|
15
|
+
"./service": "./src/service.js",
|
|
16
|
+
"./cron-parser": "./src/cron-parser.js",
|
|
17
|
+
"./schedule": "./src/schedule.js",
|
|
18
|
+
"./job": "./src/job.js",
|
|
19
|
+
"./normalize": "./src/normalize.js",
|
|
20
|
+
"./locked": "./src/locked.js",
|
|
21
|
+
"./run-log": "./src/run-log.js",
|
|
15
22
|
"./min-heap": "./src/min-heap.js"
|
|
16
23
|
},
|
|
17
24
|
"publishConfig": {
|
|
@@ -32,14 +39,14 @@
|
|
|
32
39
|
},
|
|
33
40
|
"homepage": "https://github.com/abofs/stonyx-cron#readme",
|
|
34
41
|
"devDependencies": {
|
|
35
|
-
"@stonyx/utils": "
|
|
42
|
+
"@stonyx/utils": "0.2.3-beta.7",
|
|
36
43
|
"qunit": "^2.24.1",
|
|
37
44
|
"sinon": "^21.0.0"
|
|
38
45
|
},
|
|
39
46
|
"dependencies": {
|
|
40
|
-
"stonyx": "
|
|
47
|
+
"stonyx": "0.2.3-beta.11"
|
|
41
48
|
},
|
|
42
49
|
"scripts": {
|
|
43
|
-
"test": "
|
|
50
|
+
"test": "stonyx test"
|
|
44
51
|
}
|
|
45
52
|
}
|
package/pnpm-lock.yaml
CHANGED
|
@@ -9,12 +9,12 @@ importers:
|
|
|
9
9
|
.:
|
|
10
10
|
dependencies:
|
|
11
11
|
stonyx:
|
|
12
|
-
specifier:
|
|
13
|
-
version: 0.2.
|
|
12
|
+
specifier: 0.2.3-beta.11
|
|
13
|
+
version: 0.2.3-beta.11
|
|
14
14
|
devDependencies:
|
|
15
15
|
'@stonyx/utils':
|
|
16
|
-
specifier:
|
|
17
|
-
version: 0.2.
|
|
16
|
+
specifier: 0.2.3-beta.7
|
|
17
|
+
version: 0.2.3-beta.7
|
|
18
18
|
qunit:
|
|
19
19
|
specifier: ^2.24.1
|
|
20
20
|
version: 2.25.0
|
|
@@ -33,8 +33,8 @@ packages:
|
|
|
33
33
|
'@sinonjs/samsam@8.0.3':
|
|
34
34
|
resolution: {integrity: sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==}
|
|
35
35
|
|
|
36
|
-
'@stonyx/utils@0.2.
|
|
37
|
-
resolution: {integrity: sha512-
|
|
36
|
+
'@stonyx/utils@0.2.3-beta.7':
|
|
37
|
+
resolution: {integrity: sha512-SF6ZZZJ/f1n/+SJJDj8BJdlgvv+WDLGWGUMIkwTpruA5jv/AYJqSoVIgTpYfuMTnYDIsp3KoruaIKy/qd2ETPQ==}
|
|
38
38
|
|
|
39
39
|
call-bind-apply-helpers@1.0.2:
|
|
40
40
|
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
|
@@ -164,8 +164,9 @@ packages:
|
|
|
164
164
|
sinon@21.0.1:
|
|
165
165
|
resolution: {integrity: sha512-Z0NVCW45W8Mg5oC/27/+fCqIHFnW8kpkFOq0j9XJIev4Ld0mKmERaZv5DMLAb9fGCevjKwaEeIQz5+MBXfZcDw==}
|
|
166
166
|
|
|
167
|
-
stonyx@0.2.
|
|
168
|
-
resolution: {integrity: sha512-
|
|
167
|
+
stonyx@0.2.3-beta.11:
|
|
168
|
+
resolution: {integrity: sha512-HqDH7/8q7JeuTOdlekY8BGAM8FGHWTLKPWhd3xC6dAYBvK1zQWJrzINbIIymemReBxy/sDw3cs7PG8kcoOK1dg==}
|
|
169
|
+
hasBin: true
|
|
169
170
|
|
|
170
171
|
supports-color@7.2.0:
|
|
171
172
|
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
|
@@ -204,7 +205,7 @@ snapshots:
|
|
|
204
205
|
'@sinonjs/commons': 3.0.1
|
|
205
206
|
type-detect: 4.1.0
|
|
206
207
|
|
|
207
|
-
'@stonyx/utils@0.2.
|
|
208
|
+
'@stonyx/utils@0.2.3-beta.7': {}
|
|
208
209
|
|
|
209
210
|
call-bind-apply-helpers@1.0.2:
|
|
210
211
|
dependencies:
|
|
@@ -342,7 +343,7 @@ snapshots:
|
|
|
342
343
|
diff: 8.0.3
|
|
343
344
|
supports-color: 7.2.0
|
|
344
345
|
|
|
345
|
-
stonyx@0.2.
|
|
346
|
+
stonyx@0.2.3-beta.11:
|
|
346
347
|
dependencies:
|
|
347
348
|
node-chronicle: 0.2.0
|
|
348
349
|
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 5-field cron expression parser with next-occurrence computation.
|
|
3
|
+
* No external dependencies — built for stonyx-cron.
|
|
4
|
+
*
|
|
5
|
+
* Fields: minute(0-59) hour(0-23) day-of-month(1-31) month(1-12) day-of-week(0-6)
|
|
6
|
+
* Supports: wildcards(*), ranges(1-5), steps(* /5), lists(1,3,5), names(jan-dec, sun-sat)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const MONTH_NAMES = { jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12 };
|
|
10
|
+
const DAY_NAMES = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 };
|
|
11
|
+
|
|
12
|
+
const FIELD_RANGES = [
|
|
13
|
+
{ min: 0, max: 59 }, // minute
|
|
14
|
+
{ min: 0, max: 23 }, // hour
|
|
15
|
+
{ min: 1, max: 31 }, // day of month
|
|
16
|
+
{ min: 1, max: 12 }, // month
|
|
17
|
+
{ min: 0, max: 6 }, // day of week
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parse a single cron field into a sorted array of allowed values.
|
|
22
|
+
* @param {string} field - The field string (e.g., "1-5", "* /15", "mon,wed,fri")
|
|
23
|
+
* @param {number} fieldIndex - Index (0=minute, 1=hour, 2=dom, 3=month, 4=dow)
|
|
24
|
+
* @returns {number[]} Sorted array of allowed integer values
|
|
25
|
+
*/
|
|
26
|
+
export function parseField(field, fieldIndex) {
|
|
27
|
+
const { min, max } = FIELD_RANGES[fieldIndex];
|
|
28
|
+
const names = fieldIndex === 3 ? MONTH_NAMES : fieldIndex === 4 ? DAY_NAMES : null;
|
|
29
|
+
|
|
30
|
+
const resolveToken = (token) => {
|
|
31
|
+
if (names) {
|
|
32
|
+
const lower = token.toLowerCase();
|
|
33
|
+
if (lower in names) return names[lower];
|
|
34
|
+
}
|
|
35
|
+
const n = Number(token);
|
|
36
|
+
if (!Number.isInteger(n)) throw new Error(`Invalid cron value: "${token}" in field ${fieldIndex}`);
|
|
37
|
+
// Normalize day-of-week 7 → 0 (both mean Sunday)
|
|
38
|
+
if (fieldIndex === 4 && n === 7) return 0;
|
|
39
|
+
return n;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const results = new Set();
|
|
43
|
+
|
|
44
|
+
for (const part of field.split(',')) {
|
|
45
|
+
const trimmed = part.trim();
|
|
46
|
+
const [rangeStr, stepStr] = trimmed.split('/');
|
|
47
|
+
const step = stepStr !== undefined ? Number(stepStr) : 1;
|
|
48
|
+
|
|
49
|
+
if (!Number.isInteger(step) || step < 1) {
|
|
50
|
+
throw new Error(`Invalid step "${stepStr}" in cron field ${fieldIndex}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let start, end;
|
|
54
|
+
|
|
55
|
+
if (rangeStr === '*') {
|
|
56
|
+
start = min;
|
|
57
|
+
end = max;
|
|
58
|
+
} else if (rangeStr.includes('-')) {
|
|
59
|
+
const [lo, hi] = rangeStr.split('-');
|
|
60
|
+
start = resolveToken(lo);
|
|
61
|
+
end = resolveToken(hi);
|
|
62
|
+
} else {
|
|
63
|
+
start = resolveToken(rangeStr);
|
|
64
|
+
end = stepStr !== undefined ? max : start;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (start < min || start > max || end < min || end > max) {
|
|
68
|
+
throw new Error(`Value out of range [${min}-${max}] in cron field ${fieldIndex}: "${trimmed}"`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (let v = start; v <= end; v += step) {
|
|
72
|
+
results.add(v);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return [...results].sort((a, b) => a - b);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Parse a 5-field cron expression into field arrays.
|
|
81
|
+
* @param {string} expr - Cron expression (e.g., "0 9 * * 1-5")
|
|
82
|
+
* @returns {{ minutes: number[], hours: number[], daysOfMonth: number[], months: number[], daysOfWeek: number[] }}
|
|
83
|
+
*/
|
|
84
|
+
export function parseCronExpression(expr) {
|
|
85
|
+
const fields = expr.trim().split(/\s+/);
|
|
86
|
+
if (fields.length !== 5) {
|
|
87
|
+
throw new Error(`Cron expression must have exactly 5 fields, got ${fields.length}: "${expr}"`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
minutes: parseField(fields[0], 0),
|
|
92
|
+
hours: parseField(fields[1], 1),
|
|
93
|
+
daysOfMonth: parseField(fields[2], 2),
|
|
94
|
+
months: parseField(fields[3], 3),
|
|
95
|
+
daysOfWeek: parseField(fields[4], 4),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Get the number of days in a given month/year.
|
|
101
|
+
*/
|
|
102
|
+
function daysInMonth(year, month) {
|
|
103
|
+
return new Date(year, month, 0).getDate();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Check if a day-of-month + day-of-week pair matches the parsed expression.
|
|
108
|
+
*
|
|
109
|
+
* Standard cron behavior: if BOTH dom and dow are restricted (not *),
|
|
110
|
+
* then EITHER matching is sufficient (OR logic).
|
|
111
|
+
* If only one is restricted, it acts as the sole filter.
|
|
112
|
+
*/
|
|
113
|
+
function dayMatches(parsed, domWild, dowWild, dayOfMonth, dayOfWeek) {
|
|
114
|
+
const domMatch = parsed.daysOfMonth.includes(dayOfMonth);
|
|
115
|
+
const dowMatch = parsed.daysOfWeek.includes(dayOfWeek);
|
|
116
|
+
|
|
117
|
+
if (domWild && dowWild) return true;
|
|
118
|
+
if (domWild) return dowMatch;
|
|
119
|
+
if (dowWild) return domMatch;
|
|
120
|
+
return domMatch || dowMatch; // Both restricted → OR
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Compute the next occurrence of a cron expression after a given timestamp.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} expr - 5-field cron expression
|
|
127
|
+
* @param {number} afterMs - Timestamp in milliseconds (exclusive — finds strictly after this)
|
|
128
|
+
* @param {string} [tz] - IANA timezone (defaults to system timezone)
|
|
129
|
+
* @returns {number|undefined} Next occurrence in milliseconds, or undefined if none within 4 years
|
|
130
|
+
*/
|
|
131
|
+
export function nextOccurrence(expr, afterMs, tz) {
|
|
132
|
+
const parsed = parseCronExpression(expr);
|
|
133
|
+
const exprFields = expr.trim().split(/\s+/);
|
|
134
|
+
const domWild = exprFields[2] === '*';
|
|
135
|
+
const dowWild = exprFields[4] === '*';
|
|
136
|
+
|
|
137
|
+
// Start from the next whole minute after afterMs
|
|
138
|
+
const startDate = new Date(afterMs);
|
|
139
|
+
startDate.setSeconds(0, 0);
|
|
140
|
+
startDate.setMinutes(startDate.getMinutes() + 1);
|
|
141
|
+
|
|
142
|
+
// Convert to target timezone for field matching
|
|
143
|
+
const formatter = new Intl.DateTimeFormat('en-US', {
|
|
144
|
+
timeZone: tz || undefined,
|
|
145
|
+
year: 'numeric', month: 'numeric', day: 'numeric',
|
|
146
|
+
hour: 'numeric', minute: 'numeric', hour12: false,
|
|
147
|
+
weekday: 'short',
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const dayMap = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
|
151
|
+
|
|
152
|
+
// Parse formatted date parts in the target timezone
|
|
153
|
+
function getLocalParts(date) {
|
|
154
|
+
const parts = {};
|
|
155
|
+
for (const { type, value } of formatter.formatToParts(date)) {
|
|
156
|
+
parts[type] = value;
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
year: Number(parts.year),
|
|
160
|
+
month: Number(parts.month),
|
|
161
|
+
day: Number(parts.day),
|
|
162
|
+
hour: Number(parts.hour === '24' ? 0 : parts.hour),
|
|
163
|
+
minute: Number(parts.minute),
|
|
164
|
+
weekday: dayMap[parts.weekday] ?? 0,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Search limit: 4 years of minutes (≈ 2.1M iterations max)
|
|
169
|
+
const maxMs = afterMs + 4 * 365.25 * 24 * 60 * 60 * 1000;
|
|
170
|
+
let candidate = new Date(startDate);
|
|
171
|
+
|
|
172
|
+
while (candidate.getTime() <= maxMs) {
|
|
173
|
+
const p = getLocalParts(candidate);
|
|
174
|
+
|
|
175
|
+
// Check month
|
|
176
|
+
if (!parsed.months.includes(p.month)) {
|
|
177
|
+
// Advance to next matching month
|
|
178
|
+
const nextMonth = parsed.months.find(m => m > p.month);
|
|
179
|
+
if (nextMonth) {
|
|
180
|
+
// Stay in same year, advance to first day of nextMonth
|
|
181
|
+
candidate = advanceToMonth(candidate, p.year, nextMonth, tz, formatter, dayMap);
|
|
182
|
+
} else {
|
|
183
|
+
// Wrap to next year, first matching month
|
|
184
|
+
candidate = advanceToMonth(candidate, p.year + 1, parsed.months[0], tz, formatter, dayMap);
|
|
185
|
+
}
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Check day (dom + dow)
|
|
190
|
+
if (!dayMatches(parsed, domWild, dowWild, p.day, p.weekday)) {
|
|
191
|
+
candidate.setMinutes(candidate.getMinutes() + (24 * 60 - p.hour * 60 - p.minute));
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Check hour
|
|
196
|
+
if (!parsed.hours.includes(p.hour)) {
|
|
197
|
+
const nextHour = parsed.hours.find(h => h > p.hour);
|
|
198
|
+
if (nextHour) {
|
|
199
|
+
candidate.setMinutes(candidate.getMinutes() + ((nextHour - p.hour) * 60 - p.minute));
|
|
200
|
+
} else {
|
|
201
|
+
// Advance to next day
|
|
202
|
+
candidate.setMinutes(candidate.getMinutes() + ((24 - p.hour) * 60 - p.minute));
|
|
203
|
+
}
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Check minute
|
|
208
|
+
if (!parsed.minutes.includes(p.minute)) {
|
|
209
|
+
const nextMin = parsed.minutes.find(m => m > p.minute);
|
|
210
|
+
if (nextMin) {
|
|
211
|
+
candidate.setMinutes(candidate.getMinutes() + (nextMin - p.minute));
|
|
212
|
+
} else {
|
|
213
|
+
// Advance to next hour
|
|
214
|
+
candidate.setMinutes(candidate.getMinutes() + (60 - p.minute));
|
|
215
|
+
}
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// All fields match
|
|
220
|
+
return candidate.getTime();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Create a Date advanced to the start of a specific month in a specific year,
|
|
228
|
+
* using the target timezone's midnight.
|
|
229
|
+
*/
|
|
230
|
+
function advanceToMonth(current, year, month, tz, formatter, dayMap) {
|
|
231
|
+
// Create a new date at ~start of the target month in UTC, then adjust
|
|
232
|
+
const d = new Date(current);
|
|
233
|
+
// Jump to approximately the right time
|
|
234
|
+
d.setFullYear(year, month - 1, 1);
|
|
235
|
+
d.setHours(0, 0, 0, 0);
|
|
236
|
+
return d;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Validate a cron expression without computing next occurrence.
|
|
241
|
+
* @param {string} expr - 5-field cron expression
|
|
242
|
+
* @throws {Error} if the expression is invalid
|
|
243
|
+
*/
|
|
244
|
+
export function validateCronExpression(expr) {
|
|
245
|
+
parseCronExpression(expr);
|
|
246
|
+
}
|