Skip to content

Commit df9d4c9

Browse files
committed
fix(plugin-reports): a non-member schedule timezone no longer discards the cron expression
`ReportService.scheduleReport`'s eager guard used the callback-less `new Cron(expr, { timezone })`, which validates the EXPRESSION and lets any string through as the zone: croner defers that judgement to `nextRun()`. `nextRunAt` then caught the deferred `CronDate` TypeError and returned `from + interval_minutes`, so a schedule authored as "every weekday 09:00 Asia/Shanghai" fired every 1440 minutes forever, re-derived on every sweep through `advanceSchedule` — the verbatim outcome the guard's own comment says it exists to prevent — and the single warning it emitted named the cron expression, which was fine, rather than the timezone, which was not. - `scheduleReport` asks `isValueDomainMember('iana_time_zone', …)` from `@objectstack/spec/shared` — the same predicate `sys_report_schedule.timezone` enforces on write — so the service door and the storage door give one answer, and the refusal names the input that is actually wrong. The row now stores the same string the scheduler evaluates. - On a sweep, a schedule whose stored zone is not a member and which carries a cron expression is not run and its `next_run_at` is not advanced; `last_status` / `last_error` (both pre-existing columns) carry the reason. `active` and the past `next_run_at` are left alone deliberately, so correcting the zone resumes the schedule on the next sweep with no second action. Interval-only schedules are untouched — interval arithmetic never reads the zone. - Both fall-back warnings now name the expression AND the zone, and the second no longer asserts the expression is the broken half. Fixes #16291 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
1 parent 923caed commit df9d4c9

5 files changed

Lines changed: 373 additions & 16 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/plugin-reports": patch
3+
---
4+
5+
`ReportService` no longer discards a report schedule's cron expression when its `timezone` is not a real IANA zone — and when it does fall back to interval, it says which of the two inputs failed.
6+
7+
croner (10.0.1) answers a non-member zone in three different ways, and only the middle one was ever reached here: `new Cron(expr, { timezone })` **without a callback** validates the expression and lets any zone through, `nextRun()` on that instance then throws a `CronDate` conversion `TypeError`, and the callback form throws at construction. `scheduleReport`'s eager guard used the callback-less form, so the timezone half of its own input passed straight under a guard whose stated purpose was "a clear error at schedule time instead of a schedule that silently falls back to interval on sweep" — and `nextRunAt` caught that deferred throw and returned `from + interval_minutes`. A schedule authored as "every weekday 09:00 Asia/Shanghai" became "every 1440 minutes, forever", re-derived on every sweep, logged only as a complaint about a cron expression that was perfectly good.
8+
9+
- **The create-time guard now asks the right question.** `scheduleReport` consults `isValueDomainMember('iana_time_zone', …)` from `@objectstack/spec/shared` — the same predicate `sys_report_schedule.timezone`'s `valueDomain` declaration enforces on write — and refuses a non-member with `VALIDATION_FAILED: invalid timezone '<zone>': not a member of the 'iana_time_zone' value domain`. One answer at both doors, so this one cannot accept what the storage gate refuses; it only says so earlier and names the input that is actually wrong. It applies whether or not a `cron_expression` is set, because the storage gate does too.
10+
- **The row now stores the string the scheduler evaluates.** An empty `timezone` was stored verbatim while every `new Cron` call site read it as `UTC`; it is normalised to `UTC` on the way in.
11+
- **A schedule already holding an unusable zone is stopped, not rescheduled.** `valueDomain` is written-values-only, so rows stored before that declaration are never re-validated and no refusal reaches them. On a sweep, a schedule with a `cron_expression` whose zone is not a member is now not run and its `next_run_at` is not advanced; `last_status` becomes `failed` and `last_error` names the zone, the expression and what to do. `active` stays set and `next_run_at` stays in the past deliberately — the same posture this loop already takes for a schedule whose report has vanished — so correcting the zone resumes the schedule on the next sweep with no second action. Interval-only schedules are untouched: interval arithmetic never consults the zone, so a legacy bad value there still delivers on the cadence its author asked for.
12+
- **Both fall-back warnings name both inputs.** The "no next occurrence" and the former "invalid cron" lines each mentioned only the expression, so either of them on a timezone fault sent an investigator to audit the half that was fine. They now carry the expression *and* the zone, and the second no longer asserts the expression is the broken one.

packages/platform-objects/src/audit/sys-report-schedule.object.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,18 @@ export const SysReportSchedule = ObjectSchema.create({
9898
// `invalid cron '<expr>'` — a warning that names the wrong input, since the
9999
// expression was fine. Neither a throw nor a fall back to UTC: the wrong
100100
// instant, permanently, which is the outcome this card was told to escalate
101-
// on. `scheduleReport`'s eager create-time guard does not catch it either;
102-
// it constructs a callback-less `Cron` and so is blind to exactly this half
103-
// of its own input. Refusing the write is what closes it.
101+
// on. `scheduleReport`'s eager create-time guard did not catch it either;
102+
// it constructed a callback-less `Cron` and so was blind to exactly this
103+
// half of its own input. Refusing the write is what closes it HERE.
104+
//
105+
// [#16291] The reader's two halves are closed separately, and this line does
106+
// not stand in for either: `scheduleReport` now consults
107+
// `isValueDomainMember('iana_time_zone', …)` itself — this declaration's own
108+
// predicate, so neither door can accept what the other refuses — and the
109+
// sweep quarantines a row that was STORED before this line existed (it does
110+
// not run it and does not advance `next_run_at`, and says so in
111+
// `last_status` / `last_error`) rather than re-deriving a cadence from
112+
// `interval_minutes` that nobody asked for.
104113
//
105114
// `maxLength: 64` and `defaultValue: 'UTC'` are BOTH unchanged. The bound is
106115
// already the value #14238 justified (twice the domain's real ceiling: the

packages/plugins/plugin-reports/src/report-service.test.ts

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,197 @@ describe('ReportService', () => {
465465
expect(engine._tables['sys_report_schedule'][0].last_status).toBe('ok');
466466
});
467467

468+
// ─── Schedule timezone (#16291) ─────────────────────────────────
469+
//
470+
// croner 10.0.1 has a THREE-state answer to a non-member IANA zone, and only
471+
// the middle one was ever reached here (measured on Node v22.22.2):
472+
//
473+
// new Cron('0 9 * * *', { timezone: 'Mars/Olympus' }) -> constructs FINE
474+
// .nextRun(from) -> TypeError: CronDate …
475+
// new Cron('0 9 * * *', { timezone: 'Mars/Olympus' }, async () => {}) -> throws at construction
476+
//
477+
// So the create-time guard, which used the callback-less form, validated the
478+
// expression and was blind to the zone; and `nextRunAt` caught the deferred
479+
// throw and fell back to `interval_minutes` — turning "weekdays 09:00
480+
// Asia/Shanghai" into "every 1440 minutes, forever", logged as a complaint
481+
// about a cron expression that was perfectly good.
482+
describe('schedule timezone', () => {
483+
const BAD_TZ = 'Mars/Olympus';
484+
485+
/** Store a schedule row directly — the shape a pre-#15872 row has. */
486+
function seedScheduleRow(reportId: string, patch: Record<string, unknown>) {
487+
const row = {
488+
id: 'rsch_legacy',
489+
report_id: reportId,
490+
name: null,
491+
interval_minutes: 1440,
492+
cron_expression: null,
493+
timezone: 'UTC',
494+
active: true,
495+
recipients: 'ops@t',
496+
format: 'html_table',
497+
subject_template: null,
498+
owner_id: 'u1',
499+
next_run_at: new Date(now.getTime() - 1000).toISOString(),
500+
created_at: now.toISOString(),
501+
updated_at: now.toISOString(),
502+
...patch,
503+
};
504+
(engine._tables['sys_report_schedule'] ??= []).push(row);
505+
return row;
506+
}
507+
508+
// ── The create-time door ──
509+
510+
it('scheduleReport: refuses a non-member timezone instead of storing it', async () => {
511+
const r = await svc.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
512+
await expect(svc.scheduleReport({
513+
reportId: r.id, recipients: ['x@t'], cronExpression: '0 9 * * *', timezone: BAD_TZ,
514+
}, CTX)).rejects.toThrow(/VALIDATION_FAILED/);
515+
// Names the input that is actually wrong — not the cron expression, which
516+
// is valid, and which the old guard was the only thing to mention.
517+
await expect(svc.scheduleReport({
518+
reportId: r.id, recipients: ['x@t'], cronExpression: '0 9 * * *', timezone: BAD_TZ,
519+
}, CTX)).rejects.toThrow(new RegExp(`timezone '${BAD_TZ}'`));
520+
expect(engine._tables['sys_report_schedule'] ?? []).toHaveLength(0);
521+
});
522+
523+
it('scheduleReport: refuses a non-member timezone with no cron_expression too', async () => {
524+
// One answer at both doors. `sys_report_schedule.timezone` carries
525+
// `valueDomain: 'iana_time_zone'` (#15872), which refuses the value on
526+
// WRITE whether or not a cron is set; a guard that accepted it here for
527+
// interval schedules would hand the engine a row it is about to reject and
528+
// report the divergence as a generic field error.
529+
const r = await svc.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
530+
await expect(svc.scheduleReport({
531+
reportId: r.id, recipients: ['x@t'], intervalMinutes: 60, timezone: BAD_TZ,
532+
}, CTX)).rejects.toThrow(new RegExp(`VALIDATION_FAILED.*timezone '${BAD_TZ}'`));
533+
});
534+
535+
it('scheduleReport: the guard uses the shared predicate, so real zones still pass', async () => {
536+
const r = await svc.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
537+
for (const tz of ['UTC', 'Asia/Shanghai', 'America/New_York', 'Etc/GMT+8']) {
538+
const s = await svc.scheduleReport({
539+
reportId: r.id, recipients: ['x@t'], cronExpression: '0 9 * * *', timezone: tz,
540+
}, CTX);
541+
expect(s.timezone).toBe(tz);
542+
}
543+
});
544+
545+
it('scheduleReport: stores the same zone string the scheduler evaluates', async () => {
546+
// `''` is not an `iana_time_zone` member, but every `new Cron` call site
547+
// reads it as UTC via `|| 'UTC'`. The row must not keep a value the storage
548+
// gate refuses while the scheduler quietly treats it as something else.
549+
const r = await svc.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
550+
const s = await svc.scheduleReport({
551+
reportId: r.id, recipients: ['x@t'], cronExpression: '0 9 * * *', timezone: '',
552+
}, CTX);
553+
expect(s.timezone).toBe('UTC');
554+
expect(engine._tables['sys_report_schedule'][0].timezone).toBe('UTC');
555+
expect(s.next_run_at).toBe('2026-01-16T09:00:00.000Z');
556+
});
557+
558+
// ── The stored-row door: rows written before #15872 ──
559+
560+
it('dispatchDue: a stored non-member timezone stops the schedule instead of rescheduling it', async () => {
561+
const r = await svc.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
562+
const seeded = seedScheduleRow(r.id, {
563+
cron_expression: '0 9 * * 1-5', timezone: BAD_TZ, format: 'csv',
564+
});
565+
566+
const result = await svc.dispatchDue();
567+
568+
expect(result).toEqual({ fired: 0, failed: 1, skipped: 0 });
569+
expect(email._sent).toHaveLength(0);
570+
const stored = engine._tables['sys_report_schedule'][0];
571+
expect(stored.last_status).toBe('failed');
572+
expect(stored.last_error).toContain(BAD_TZ);
573+
expect(stored.last_error).toContain('0 9 * * 1-5');
574+
// NOT advanced to `now + interval_minutes` — the whole defect was that it
575+
// was, on every sweep, forever.
576+
expect(stored.next_run_at).toBe(seeded.next_run_at);
577+
expect(stored.next_run_at).not.toBe(new Date(now.getTime() + 1440 * 60_000).toISOString());
578+
});
579+
580+
it('dispatchDue: an interval-only schedule with a stored bad zone is left alone', async () => {
581+
// The zone is load-bearing only for cron evaluation; interval arithmetic
582+
// never consults it. Quarantining these would stop deliveries that are
583+
// landing exactly when their author asked for them.
584+
const r = await svc.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
585+
seedScheduleRow(r.id, { cron_expression: null, interval_minutes: 60, timezone: BAD_TZ });
586+
587+
const result = await svc.dispatchDue();
588+
589+
expect(result.fired).toBe(1);
590+
expect(email._sent).toHaveLength(1);
591+
const stored = engine._tables['sys_report_schedule'][0];
592+
expect(stored.last_status).toBe('ok');
593+
expect(stored.next_run_at).toBe(new Date(now.getTime() + 60 * 60_000).toISOString());
594+
});
595+
596+
it('dispatchDue: correcting the stored zone resumes the schedule with no other action', async () => {
597+
// Why the quarantine leaves `active` set and `next_run_at` in the past:
598+
// the row stays due, so the sweep picks it up again by itself.
599+
const r = await svc.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
600+
seedScheduleRow(r.id, { cron_expression: '0 9 * * *', timezone: BAD_TZ, format: 'csv' });
601+
602+
expect((await svc.dispatchDue()).failed).toBe(1);
603+
engine._tables['sys_report_schedule'][0].timezone = 'Asia/Shanghai';
604+
605+
const result = await svc.dispatchDue();
606+
expect(result.fired).toBe(1);
607+
expect(email._sent).toHaveLength(1);
608+
const stored = engine._tables['sys_report_schedule'][0];
609+
expect(stored.last_status).toBe('ok');
610+
// 09:00 Asia/Shanghai (UTC+8) on the 16th = 01:00Z — the instant its author
611+
// actually asked for, not `now + 1440m`.
612+
expect(stored.next_run_at).toBe('2026-01-16T01:00:00.000Z');
613+
});
614+
615+
// ── The warning text: both paths, neither pointing at the wrong input ──
616+
617+
it('nextRunAt: the no-occurrence warning names the timezone as well as the cron', async () => {
618+
const warn = vi.fn();
619+
const logged = new ReportService({
620+
engine: engine as any, email, clock: { now: () => now }, logger: { warn },
621+
resolveOwnerContext: async (id: string) => ({ userId: id, positions: [], permissions: [] }),
622+
});
623+
const r = await logged.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
624+
// 30 February never occurs; croner returns null rather than throwing.
625+
await logged.scheduleReport({
626+
reportId: r.id, recipients: ['x@t'], cronExpression: '0 0 30 2 *', timezone: 'Asia/Shanghai',
627+
}, CTX);
628+
629+
const line = warn.mock.calls.map(c => String(c[0])).find(m => m.includes('no next occurrence'));
630+
expect(line).toBeDefined();
631+
expect(line).toContain("timezone 'Asia/Shanghai'");
632+
expect(line).toContain("cron '0 0 30 2 *'");
633+
});
634+
635+
it('nextRunAt: the un-evaluatable warning names the timezone and stops calling the cron invalid', async () => {
636+
const warn = vi.fn();
637+
const logged = new ReportService({
638+
engine: engine as any, email, clock: { now: () => now }, logger: { warn },
639+
resolveOwnerContext: async (id: string) => ({ userId: id, positions: [], permissions: [] }),
640+
});
641+
const r = await logged.saveReport({ name: 'A', object: 'lead', query: {} }, CTX);
642+
// A row whose cron the create-time guard would have refused — the shape
643+
// that reaches `nextRunAt` through `advanceSchedule` on a sweep.
644+
seedScheduleRow(r.id, { cron_expression: 'not a cron', timezone: 'Asia/Shanghai' });
645+
646+
await logged.dispatchDue();
647+
648+
const line = warn.mock.calls.map(c => String(c[0])).find(m => m.includes('could not be evaluated'));
649+
expect(line).toBeDefined();
650+
expect(line).toContain("timezone 'Asia/Shanghai'");
651+
expect(line).toContain("cron 'not a cron'");
652+
// The old text asserted the expression was the broken half. On a timezone
653+
// fault that accusation was simply false, and it is the reason this card
654+
// treats the warning as part of the defect rather than as cosmetics.
655+
expect(warn.mock.calls.map(c => String(c[0])).join('\n')).not.toContain('invalid cron');
656+
});
657+
});
658+
468659
// ─── Authorization (#2980) ──────────────────────────────────────
469660
describe('access control', () => {
470661
const OTHER = { userId: 'u2', tenantId: 't1', positions: [], permissions: [] };

0 commit comments

Comments
 (0)