nucleus-core-ts 0.9.853 → 0.9.855
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.
|
@@ -146,7 +146,18 @@ export type RunStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancell
|
|
|
146
146
|
*/
|
|
147
147
|
export type RunIssue = {
|
|
148
148
|
sourceIndex: number;
|
|
149
|
-
kind: 'missing_required' | 'missing_dedup_key' | 'duplicate_in_batch' | 'duplicate_value' | 'invalid_value' | 'unmapped' | 'missing_reference' | 'account_unmatched'
|
|
149
|
+
kind: 'missing_required' | 'missing_dedup_key' | 'duplicate_in_batch' | 'duplicate_value' | 'invalid_value' | 'unmapped' | 'missing_reference' | 'account_unmatched'
|
|
150
|
+
/**
|
|
151
|
+
* An account was made, but the row that gives it a ROLE was not.
|
|
152
|
+
*
|
|
153
|
+
* The two are written separately — the account first, its membership after
|
|
154
|
+
* — and the membership was dropped without a word whenever the table it
|
|
155
|
+
* points at could not be resolved, or the account it belongs to could not
|
|
156
|
+
* be found again. Both leave a person who can sign in and do nothing, and
|
|
157
|
+
* the only evidence is a table nobody thinks to open. Reported so the run
|
|
158
|
+
* says it out loud instead.
|
|
159
|
+
*/
|
|
160
|
+
| 'account_role_missing';
|
|
150
161
|
message: string;
|
|
151
162
|
dedupKey?: string | null;
|
|
152
163
|
/**
|
|
@@ -121,6 +121,14 @@ export type ApplyResult = {
|
|
|
121
121
|
deleted: number;
|
|
122
122
|
deactivated: number;
|
|
123
123
|
preImages: PreImage[];
|
|
124
|
+
/**
|
|
125
|
+
* What was written INCOMPLETELY, rather than not at all.
|
|
126
|
+
*
|
|
127
|
+
* A role membership that could not be written leaves an account that can
|
|
128
|
+
* sign in and do nothing. It used to be skipped in silence — the run
|
|
129
|
+
* reported success and the only evidence was a join table nobody opens.
|
|
130
|
+
*/
|
|
131
|
+
issues: RunIssue[];
|
|
124
132
|
} | {
|
|
125
133
|
ok: false;
|
|
126
134
|
failure: RunFailure;
|
|
@@ -41,10 +41,25 @@ export declare function matchesCron(fields: CronFields, when: Date, timeZone: st
|
|
|
41
41
|
/**
|
|
42
42
|
* Is this schedule due, given when it last ran?
|
|
43
43
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
44
|
+
* ## Why this looks at more than the current minute
|
|
45
|
+
*
|
|
46
|
+
* It used to ask one question — "does the minute `now` falls in match?" — and
|
|
47
|
+
* the scheduler asks it once a minute from a `setInterval`. `setInterval` drifts:
|
|
48
|
+
* a tick at 09:00:59.8 and the next at 09:02:00.1 never samples 09:01, so a
|
|
49
|
+
* schedule of `1 9 * * *` simply did not run that day. Nothing was logged,
|
|
50
|
+
* because from the scheduler's point of view nothing was due. The same hole
|
|
51
|
+
* swallows any minute lost to a busy event loop, a slow query on the tick before,
|
|
52
|
+
* or a container restarting across the boundary.
|
|
53
|
+
*
|
|
54
|
+
* So the question is now "has a matching minute passed that we have not run
|
|
55
|
+
* for?" — every minute after `lastRunAt`, up to and including this one, bounded
|
|
56
|
+
* by {@link CATCH_UP_MINUTES}. A schedule that has never run keeps the old,
|
|
57
|
+
* stricter rule: firing on the exact minute only, so turning one on does not
|
|
58
|
+
* replay a history it was never part of.
|
|
59
|
+
*
|
|
60
|
+
* It still refuses to answer twice for the same minute, which is what keeps a
|
|
61
|
+
* scheduler that ticks more often than once a minute from starting the same
|
|
62
|
+
* import several times.
|
|
48
63
|
*/
|
|
49
64
|
export declare function isDue(input: {
|
|
50
65
|
expression: string;
|
package/dist/src/Shared/cron.js
CHANGED
|
@@ -149,13 +149,36 @@ export function parseCron(expression) {
|
|
|
149
149
|
if (fields.anyDayOfWeek) return dayOfMonthMatches;
|
|
150
150
|
return dayOfMonthMatches || dayOfWeekMatches;
|
|
151
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* How far back a missed schedule is still worth catching up.
|
|
154
|
+
*
|
|
155
|
+
* A day and an hour: long enough to cover a night's outage or a deploy, short
|
|
156
|
+
* enough that a mapping paused for a month does not fire the moment it is turned
|
|
157
|
+
* back on. Counted in minutes because that is the resolution cron has.
|
|
158
|
+
*/ const CATCH_UP_MINUTES = 25 * 60;
|
|
159
|
+
const MINUTE_MS = 60000;
|
|
152
160
|
/**
|
|
153
161
|
* Is this schedule due, given when it last ran?
|
|
154
162
|
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
163
|
+
* ## Why this looks at more than the current minute
|
|
164
|
+
*
|
|
165
|
+
* It used to ask one question — "does the minute `now` falls in match?" — and
|
|
166
|
+
* the scheduler asks it once a minute from a `setInterval`. `setInterval` drifts:
|
|
167
|
+
* a tick at 09:00:59.8 and the next at 09:02:00.1 never samples 09:01, so a
|
|
168
|
+
* schedule of `1 9 * * *` simply did not run that day. Nothing was logged,
|
|
169
|
+
* because from the scheduler's point of view nothing was due. The same hole
|
|
170
|
+
* swallows any minute lost to a busy event loop, a slow query on the tick before,
|
|
171
|
+
* or a container restarting across the boundary.
|
|
172
|
+
*
|
|
173
|
+
* So the question is now "has a matching minute passed that we have not run
|
|
174
|
+
* for?" — every minute after `lastRunAt`, up to and including this one, bounded
|
|
175
|
+
* by {@link CATCH_UP_MINUTES}. A schedule that has never run keeps the old,
|
|
176
|
+
* stricter rule: firing on the exact minute only, so turning one on does not
|
|
177
|
+
* replay a history it was never part of.
|
|
178
|
+
*
|
|
179
|
+
* It still refuses to answer twice for the same minute, which is what keeps a
|
|
180
|
+
* scheduler that ticks more often than once a minute from starting the same
|
|
181
|
+
* import several times.
|
|
159
182
|
*/ export function isDue(input) {
|
|
160
183
|
const parsed = parseCron(input.expression);
|
|
161
184
|
if (!parsed.ok) return {
|
|
@@ -163,26 +186,32 @@ export function parseCron(expression) {
|
|
|
163
186
|
error: parsed.error
|
|
164
187
|
};
|
|
165
188
|
const zone = input.timeZone?.trim() || 'UTC';
|
|
166
|
-
|
|
189
|
+
const nowMinute = Math.floor(input.now.getTime() / MINUTE_MS);
|
|
190
|
+
/** How many minutes back to look, inclusive of this one. */ let lookBack = 0;
|
|
191
|
+
if (input.lastRunAt) {
|
|
192
|
+
const lastMinute = Math.floor(input.lastRunAt.getTime() / MINUTE_MS);
|
|
193
|
+
// Already answered for this minute — or the clock moved backwards, which is
|
|
194
|
+
// not a reason to fire.
|
|
195
|
+
if (lastMinute >= nowMinute) return {
|
|
196
|
+
due: false
|
|
197
|
+
};
|
|
198
|
+
lookBack = Math.min(nowMinute - lastMinute - 1, CATCH_UP_MINUTES);
|
|
199
|
+
}
|
|
167
200
|
try {
|
|
168
|
-
|
|
201
|
+
for(let back = 0; back <= lookBack; back++){
|
|
202
|
+
const at = new Date((nowMinute - back) * MINUTE_MS);
|
|
203
|
+
if (matchesCron(parsed.fields, at, zone)) return {
|
|
204
|
+
due: true
|
|
205
|
+
};
|
|
206
|
+
}
|
|
169
207
|
} catch {
|
|
170
208
|
return {
|
|
171
209
|
due: false,
|
|
172
210
|
error: `"${zone}" is not a timezone this system knows.`
|
|
173
211
|
};
|
|
174
212
|
}
|
|
175
|
-
if (!matches) return {
|
|
176
|
-
due: false
|
|
177
|
-
};
|
|
178
|
-
if (input.lastRunAt) {
|
|
179
|
-
const sameMinute = Math.floor(input.lastRunAt.getTime() / 60000) === Math.floor(input.now.getTime() / 60000);
|
|
180
|
-
if (sameMinute) return {
|
|
181
|
-
due: false
|
|
182
|
-
};
|
|
183
|
-
}
|
|
184
213
|
return {
|
|
185
|
-
due:
|
|
214
|
+
due: false
|
|
186
215
|
};
|
|
187
216
|
}
|
|
188
217
|
/** Plain words for a schedule, so nobody has to read cron to know what they set. */ export function describeCron(expression, timeZone) {
|
|
@@ -139,3 +139,85 @@ describe('saying it in plain words', ()=>{
|
|
|
139
139
|
expect(describeCron('nope')).toContain('five parts');
|
|
140
140
|
});
|
|
141
141
|
});
|
|
142
|
+
/**
|
|
143
|
+
* The scheduler ticks once a minute from a `setInterval`, and `setInterval`
|
|
144
|
+
* drifts. A tick at 09:00:59.8 followed by one at 09:02:00.1 never samples
|
|
145
|
+
* 09:01 — so a daily `1 9 * * *` did not run that day, and nothing was logged,
|
|
146
|
+
* because nothing was ever asked about that minute.
|
|
147
|
+
*/ describe('isDue — a minute the scheduler never sampled', ()=>{
|
|
148
|
+
const daily = '1 9 * * *';
|
|
149
|
+
const at = (iso)=>new Date(iso);
|
|
150
|
+
it('fires for a matching minute that was skipped between two ticks', ()=>{
|
|
151
|
+
expect(isDue({
|
|
152
|
+
expression: daily,
|
|
153
|
+
now: at('2026-03-04T09:02:00.100Z'),
|
|
154
|
+
lastRunAt: at('2026-03-03T09:01:00.000Z')
|
|
155
|
+
}).due).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
it('still fires on the exact minute, as before', ()=>{
|
|
158
|
+
expect(isDue({
|
|
159
|
+
expression: daily,
|
|
160
|
+
now: at('2026-03-04T09:01:00.000Z'),
|
|
161
|
+
lastRunAt: at('2026-03-03T09:01:00.000Z')
|
|
162
|
+
}).due).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
it('does not fire twice for the same minute', ()=>{
|
|
165
|
+
expect(isDue({
|
|
166
|
+
expression: daily,
|
|
167
|
+
now: at('2026-03-04T09:01:30.000Z'),
|
|
168
|
+
lastRunAt: at('2026-03-04T09:01:02.000Z')
|
|
169
|
+
}).due).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
it('does not fire for a minute that does not match', ()=>{
|
|
172
|
+
expect(isDue({
|
|
173
|
+
expression: daily,
|
|
174
|
+
now: at('2026-03-04T10:30:00.000Z'),
|
|
175
|
+
lastRunAt: at('2026-03-04T09:01:00.000Z')
|
|
176
|
+
}).due).toBe(false);
|
|
177
|
+
});
|
|
178
|
+
/**
|
|
179
|
+
* A schedule that has never run must not replay a history it was not part of:
|
|
180
|
+
* turning one on should not fire immediately for yesterday's slot.
|
|
181
|
+
*/ it('does not backfill for a schedule that has never run', ()=>{
|
|
182
|
+
expect(isDue({
|
|
183
|
+
expression: daily,
|
|
184
|
+
now: at('2026-03-04T09:02:00.000Z')
|
|
185
|
+
}).due).toBe(false);
|
|
186
|
+
expect(isDue({
|
|
187
|
+
expression: daily,
|
|
188
|
+
now: at('2026-03-04T09:01:00.000Z')
|
|
189
|
+
}).due).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
/**
|
|
192
|
+
* Paused for a month, then looked at again: it catches up the most recent
|
|
193
|
+
* missed slot, not thirty of them. The window is what bounds it — a daily
|
|
194
|
+
* schedule always has one inside a day, so this fires once and then the
|
|
195
|
+
* `lastRunAt` it writes keeps it from firing again.
|
|
196
|
+
*/ it('catches up the recent slot after a long pause, not the whole backlog', ()=>{
|
|
197
|
+
expect(isDue({
|
|
198
|
+
expression: daily,
|
|
199
|
+
now: at('2026-04-04T12:00:00.000Z'),
|
|
200
|
+
lastRunAt: at('2026-03-01T09:01:00.000Z')
|
|
201
|
+
}).due).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
/**
|
|
204
|
+
* And a slot that fell OUTSIDE the window stays missed. A weekly schedule
|
|
205
|
+
* whose Monday came and went three days ago is not started on Thursday: by
|
|
206
|
+
* then it is not late, it is wrong.
|
|
207
|
+
*/ it('leaves a slot older than the window alone', ()=>{
|
|
208
|
+
const mondayMorning = '0 9 * * 1';
|
|
209
|
+
expect(isDue({
|
|
210
|
+
expression: mondayMorning,
|
|
211
|
+
// Thursday noon; the Monday slot is three days back, the window is 25h.
|
|
212
|
+
now: at('2026-03-05T12:00:00.000Z'),
|
|
213
|
+
lastRunAt: at('2026-02-23T09:00:00.000Z')
|
|
214
|
+
}).due).toBe(false);
|
|
215
|
+
});
|
|
216
|
+
it('ignores a clock that moved backwards rather than firing', ()=>{
|
|
217
|
+
expect(isDue({
|
|
218
|
+
expression: daily,
|
|
219
|
+
now: at('2026-03-04T09:01:00.000Z'),
|
|
220
|
+
lastRunAt: at('2026-03-04T09:05:00.000Z')
|
|
221
|
+
}).due).toBe(false);
|
|
222
|
+
});
|
|
223
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nucleus-core-ts",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.855",
|
|
4
4
|
"description": "Production-ready, enterprise-grade TypeScript framework for building multi-tenant APIs",
|
|
5
5
|
"author": "Hidayet Can Özcan <hidayetcan@gmail.com>",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|