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
- * Answers for the minute `now` falls in, and refuses to answer twice for the
45
- * same minute — a scheduler that ticks more often than once a minute would
46
- * otherwise start the same import several times, and the run lock would be the
47
- * only thing between that and two imports writing the same rows.
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;
@@ -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
- * Answers for the minute `now` falls in, and refuses to answer twice for the
156
- * same minute — a scheduler that ticks more often than once a minute would
157
- * otherwise start the same import several times, and the run lock would be the
158
- * only thing between that and two imports writing the same rows.
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
- let matches;
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
- matches = matchesCron(parsed.fields, input.now, zone);
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: true
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.853",
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",