fix: centralize cron schedule number coercion

This commit is contained in:
Peter Steinberger
2026-05-28 21:39:02 -04:00
parent a087dbd9e9
commit b779bdb5a0
7 changed files with 114 additions and 19 deletions

View File

@@ -623,6 +623,74 @@ describe("normalizeCronJobCreate", () => {
expect(validateCronAddParams(normalized)).toBe(true);
});
it("normalizes string every schedule numbers for create jobs", () => {
const normalized = normalizeCronJobCreate({
name: "every-string",
schedule: {
everyMs: "60000",
anchorMs: "123.9",
},
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: {
kind: "systemEvent",
text: "hi",
},
}) as unknown as Record<string, unknown>;
const schedule = normalized.schedule as Record<string, unknown>;
expect(schedule).toEqual({
kind: "every",
everyMs: 60_000,
anchorMs: 123,
});
expect(validateCronAddParams(normalized)).toBe(true);
});
it("normalizes string every schedule numbers for patches", () => {
const normalized = normalizeCronJobPatch({
schedule: {
kind: "every",
everyMs: "60000",
anchorMs: "123.9",
},
}) as unknown as Record<string, unknown>;
const schedule = normalized.schedule as Record<string, unknown>;
expect(schedule).toEqual({
kind: "every",
everyMs: 60_000,
anchorMs: 123,
});
expect(validateCronUpdateParams({ id: "job", patch: normalized })).toBe(true);
});
it("keeps invalid every schedule numbers invalid for validation", () => {
const zeroEvery = normalizeCronJobCreate({
name: "every-zero",
schedule: {
kind: "every",
everyMs: "0",
},
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: {
kind: "systemEvent",
text: "hi",
},
}) as unknown as Record<string, unknown>;
expect(validateCronAddParams(zeroEvery)).toBe(false);
const negativeAnchor = normalizeCronJobPatch({
schedule: {
kind: "every",
everyMs: "60000",
anchorMs: "-1",
},
}) as unknown as Record<string, unknown>;
expect(validateCronUpdateParams({ id: "job", patch: negativeAnchor })).toBe(false);
});
it("coerces sessionTarget and wakeMode casing", () => {
const normalized = normalizeCronJobCreate({
name: "casing",

View File

@@ -13,6 +13,7 @@ import {
parseOptionalField,
} from "./delivery-field-schemas.js";
import { parseAbsoluteTimeMs } from "./parse.js";
import { coerceFiniteScheduleNumber } from "./schedule-number.js";
import { inferLegacyName } from "./service/normalize.js";
import {
assertSafeCronSessionTargetId,
@@ -73,6 +74,8 @@ function coerceSchedule(schedule: UnknownRecord) {
const exprRaw = normalizeOptionalString(schedule.expr) ?? "";
const legacyCronRaw = normalizeOptionalString(schedule.cron) ?? "";
const normalizedExpr = exprRaw || legacyCronRaw;
const everyMs = coerceFiniteScheduleNumber(schedule.everyMs);
const anchorMs = coerceFiniteScheduleNumber(schedule.anchorMs);
const atMsRaw = schedule.atMs;
const atRaw = schedule.at;
const atString = normalizeOptionalString(atRaw) ?? "";
@@ -94,7 +97,7 @@ function coerceSchedule(schedule: UnknownRecord) {
typeof schedule.atMs === "string"
) {
next.kind = "at";
} else if (typeof schedule.everyMs === "number") {
} else if (everyMs !== undefined) {
next.kind = "every";
} else if (normalizedExpr) {
next.kind = "cron";
@@ -115,6 +118,13 @@ function coerceSchedule(schedule: UnknownRecord) {
} else if ("expr" in next) {
delete next.expr;
}
if (everyMs !== undefined && everyMs >= 1) {
next.everyMs = Math.floor(everyMs);
}
if (anchorMs !== undefined && anchorMs >= 0) {
next.anchorMs = Math.floor(anchorMs);
}
if ("cron" in next) {
delete next.cron;
}

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { cronSchedulingInputsEqual, tryCronScheduleIdentity } from "./schedule-identity.js";
describe("tryCronScheduleIdentity", () => {
it("normalizes numeric schedule strings like execution does", () => {
const numeric = tryCronScheduleIdentity({
enabled: true,
schedule: { kind: "every", everyMs: 60_000, anchorMs: 123 },
});
const stringNumeric = tryCronScheduleIdentity({
enabled: true,
schedule: { kind: "every", everyMs: "60000", anchorMs: "123" },
});
expect(stringNumeric).toBe(numeric);
expect(
cronSchedulingInputsEqual(
{ schedule: { kind: "every", everyMs: 60_000, anchorMs: 123 } },
{ schedule: { kind: "every", everyMs: "60000", anchorMs: "123" } },
),
).toBe(true);
});
});

View File

@@ -1,5 +1,5 @@
import { asFiniteNumber } from "../shared/number-coercion.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { coerceFiniteScheduleNumber } from "./schedule-number.js";
import type { CronJob } from "./types.js";
function readString(record: Record<string, unknown>, key: string): string | undefined {
@@ -7,8 +7,7 @@ function readString(record: Record<string, unknown>, key: string): string | unde
}
function readNumber(record: Record<string, unknown>, key: string): number | undefined {
const value = record[key];
return asFiniteNumber(value);
return coerceFiniteScheduleNumber(record[key]);
}
function schedulePayloadFromRecord(

View File

@@ -0,0 +1,5 @@
import { parseStrictFiniteNumber } from "../shared/number-coercion.js";
export function coerceFiniteScheduleNumber(value: unknown): number | undefined {
return parseStrictFiniteNumber(value);
}

View File

@@ -250,6 +250,8 @@ describe("coerceFiniteScheduleNumber", () => {
it("returns undefined for invalid inputs", () => {
expect(coerceFiniteScheduleNumber("")).toBeUndefined();
expect(coerceFiniteScheduleNumber("abc")).toBeUndefined();
expect(coerceFiniteScheduleNumber("60000ms")).toBeUndefined();
expect(coerceFiniteScheduleNumber("0x10")).toBeUndefined();
expect(coerceFiniteScheduleNumber(Number.NaN)).toBeUndefined();
expect(coerceFiniteScheduleNumber(Infinity)).toBeUndefined();
expect(coerceFiniteScheduleNumber(null)).toBeUndefined();

View File

@@ -1,8 +1,11 @@
import { Cron } from "croner";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { parseAbsoluteTimeMs } from "./parse.js";
import { coerceFiniteScheduleNumber } from "./schedule-number.js";
import type { CronSchedule } from "./types.js";
export { coerceFiniteScheduleNumber } from "./schedule-number.js";
const CRON_EVAL_CACHE_MAX = 512;
const cronEvalCache = new Map<string, Cron>();
@@ -50,21 +53,6 @@ function resolveCronFromSchedule(schedule: {
return resolveCachedCron(expr, resolveCronTimezone(schedule.tz));
}
export function coerceFiniteScheduleNumber(value: unknown): number | undefined {
if (typeof value === "number") {
return Number.isFinite(value) ? value : undefined;
}
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): number | undefined {
if (schedule.kind === "at") {
// Handle both canonical `at` (string) and legacy `atMs` (number) fields.