All files / lib/rrm time.ts

100% Statements 52/52
100% Branches 32/32
100% Functions 12/12
100% Lines 49/49

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268                                          18x   18x 18x   18x     18x             18x                         2774x 2774x                                           18x                       224x 224x             4x                   139x                             93x                           67x 67x 67x 67x                   45x         22x 22x                     134x 134x   134x 125x             18x 51x                         45x 35x 35x 35x                                                           90x 90x   46x 46x 46x         46x   90x 90x 45x 45x 45x 1x           45x                             13x 2x       11x 2x   9x    
/**
 * IST scheduling helpers — the only place the sequence engine reasons about
 * wall-clock time.
 *
 * Workers run in UTC and the prospects live in Hyderabad. India has no DST and
 * a fixed +05:30 offset, so IST is reachable by arithmetic: shift by
 * `IST_OFFSET_MS` and read the *UTC* parts of the shifted instant. That is
 * deliberately the whole mechanism — `Intl`/timezone databases are not used,
 * and the Worker's own local time is never read, because both make the result
 * depend on the runtime rather than on the input.
 *
 * The bug this file exists to prevent: between 00:00 and 05:29 IST the IST
 * calendar date is one day *ahead* of the UTC date. A holiday compared against
 * a UTC date therefore silently misfires for every step due in those hours —
 * which is exactly when the overnight backlog is being rescheduled.
 *
 * Everything public takes and returns `Date` (the codebase boundary type)
 * except `istParts`, which takes epoch ms as the build spec specifies.
 */
 
/** IST is UTC+05:30, all year, every year. */
export const IST_OFFSET_MS = 19_800_000;
 
const MINUTE_MS = 60_000;
const HOUR_MS = 3_600_000;
/** Exported so step-schema.ts does not keep its own copy. */
export const DAY_MS = 86_400_000;
 
/** Longest jitter added to a rescheduled step. Build spec §7.2: 0–90 min. */
export const MAX_JITTER_MS = 90 * MINUTE_MS;
 
/**
 * How far the holiday skip will search before giving up. A month of
 * consecutive holidays is a corrupt config, not a calendar, and failing the
 * cron tick loudly beats messaging a thousand people on Diwali.
 */
export const MAX_HOLIDAY_LOOKAHEAD_DAYS = 30;
 
export interface IstParts {
	/** 0–23, IST. */
	hour: number;
	/** 0–59. Non-zero on the half hour because the offset is :30, not :00. */
	minute: number;
	/** IST calendar date, `YYYY-MM-DD`. Compare holidays against this. */
	dateISO: string;
}
 
/** Build spec §7.2, verbatim: shift into IST, read the UTC parts. */
export function istParts(ms: number): IstParts {
	const d = new Date(ms + IST_OFFSET_MS);
	return {
		hour: d.getUTCHours(),
		minute: d.getUTCMinutes(),
		dateISO: d.toISOString().slice(0, 10),
	};
}
 
export interface QuietHoursConfig {
	/** First sendable hour, IST. Inclusive. */
	startHour: number;
	/** First non-sendable hour, IST. Exclusive. */
	endHour: number;
	/** IST calendar dates (`YYYY-MM-DD`) on which nothing is sent. */
	holidays: readonly string[];
}
 
/**
 * The window from CONFIG §18. `holidays` is empty on purpose: the real list is
 * operator-owned and must be supplied explicitly, so this is a base to spread
 * from, never a fallback to pass to {@link isQuietHours}. Defaulting the
 * config would turn a missing holiday list into a silent holiday send.
 */
export const DEFAULT_QUIET_HOURS: QuietHoursConfig = {
	startHour: 10,
	endHour: 19,
	holidays: [],
};
 
/**
 * An empty or inverted window makes "the next send time" undefined — there
 * would be no hour at which the result of {@link nextSendWindow} is sendable,
 * so it could never be idempotent. Fail on the config rather than loop.
 */
function assertWindow(config: QuietHoursConfig): void {
	const { startHour, endHour } = config;
	if (
		!Number.isInteger(startHour) ||
		!Number.isInteger(endHour) ||
		startHour < 0 ||
		endHour > 24 ||
		startHour >= endHour
	) {
		throw new Error(
			`Invalid send window ${startHour}:00-${endHour}:00 IST: expected whole hours with 0 <= startHour < endHour <= 24`,
		);
	}
}
 
/** Epoch ms of 00:00 IST on the IST day containing `ms`. */
function istDayStartMs(ms: number): number {
	// Flooring in shifted space is only exact because every IST day is exactly
	// DAY_MS long — no DST means no 23- or 25-hour days to special-case.
	return Math.floor((ms + IST_OFFSET_MS) / DAY_MS) * DAY_MS - IST_OFFSET_MS;
}
 
/**
 * The instant 00:00 IST began for the IST day containing `date`.
 *
 * Returned as a `Date` rather than epoch ms because the caller is a daily-cap
 * count — `gte(rrmMessages.createdAt, istDayStart(now))` — and Drizzle's
 * `mode:"timestamp"` columns compare against `Date`.
 *
 * Note this is usually 18:30 UTC on the *previous* calendar day. Counting a
 * "day" of sends from UTC midnight instead would credit the 00:00–05:29 IST
 * sends to the wrong day and let the cap be exceeded by that much.
 */
export function istDayStart(date: Date): Date {
	return new Date(istDayStartMs(date.getTime()));
}
 
/**
 * The Indian financial year containing `date`, as the year it STARTS in.
 *
 * 1 April to 31 March. Read off `istParts().dateISO` rather than the UTC date
 * for the reason this whole file exists: between 00:00 and 05:29 IST the IST
 * calendar date is a day ahead of the UTC one, so a payment made at 00:30 IST
 * on 1 April is still 31 March in UTC and would be filed under the FY that
 * ended half an hour earlier. That is the only boundary in the year where it
 * matters, and it is the one TDS is assessed on.
 */
function fyStartYear(date: Date): number {
	const { dateISO } = istParts(date.getTime());
	const year = Number(dateISO.slice(0, 4));
	const month = Number(dateISO.slice(5, 7)); // 1-12
	return month >= 4 ? year : year - 1;
}
 
/**
 * Midnight IST on 1 April of `date`'s financial year, as a UTC instant.
 *
 * Suitable for `gte(column, fyStart(now))` against a `mode: "timestamp"`
 * column, the same way `istDayStart` is used for the daily send cap.
 */
export function fyStart(date: Date): Date {
	return new Date(Date.UTC(fyStartYear(date), 3, 1) - IST_OFFSET_MS);
}
 
/** `"2026-27"` — how an Indian FY is written on anything a CA will read. */
export function fyLabel(date: Date): string {
	const start = fyStartYear(date);
	return `${start}-${String((start + 1) % 100).padStart(2, "0")}`;
}
 
/**
 * True when nothing may be sent at `date`: outside the business window, or on
 * a configured holiday.
 *
 * The window is half-open — `[startHour, endHour)`. 10:00:00 sharp is
 * sendable; 19:00:00 sharp is not.
 */
export function isQuietHours(date: Date, config: QuietHoursConfig): boolean {
	assertWindow(config);
	const { hour, dateISO } = istParts(date.getTime());
	// dateISO, never a UTC date — see the file header.
	if (config.holidays.includes(dateISO)) return true;
	return hour < config.startHour || hour >= config.endHour;
}
 
/** Returns the jitter to add to a rescheduled step, in milliseconds. */
export type JitterFn = () => number;
 
/** Uniform over [0, MAX_JITTER_MS]. Injected so scheduling stays testable. */
export const defaultJitter: JitterFn = () =>
	Math.floor(Math.random() * (MAX_JITTER_MS + 1));
 
/**
 * A jitter function is supplied by the caller, so it can be wrong. Clamping
 * preserves the invariant the scheduler depends on: the returned instant is
 * inside the send window, therefore the next cron tick sees a sendable step
 * and does not reschedule it a second time.
 *
 * The window term is not redundant. With the default 9-hour window the 90 min
 * cap always binds first, but a narrow operator-set window (say 10:00–11:00)
 * would otherwise take 90 min of jitter straight back out into quiet hours.
 */
function clampJitter(raw: number, config: QuietHoursConfig): number {
	if (!Number.isFinite(raw) || raw <= 0) return 0;
	const windowMs = (config.endHour - config.startHour) * HOUR_MS;
	const ceiling = Math.max(0, Math.min(MAX_JITTER_MS, windowMs - MINUTE_MS));
	return Math.min(raw, ceiling);
}
 
/**
 * The instant a step due at `date` should actually be sent.
 *
 * Two things the build spec leaves open, resolved here:
 *
 * (a) "the next 10:00 IST" is the next *occurrence*, not tomorrow. A step that
 *     came due at 06:00 has not missed today's window — it is early. Pushing it
 *     to tomorrow would quietly cost a day on a ramp that is only five days
 *     long. Only a step past `endHour` (or on a holiday) waits for tomorrow.
 *
 * (b) jitter is injected rather than drawn from `Math.random()` inside, so the
 *     scheduler's behaviour is assertable without stubbing global randomness.
 *
 * A `date` already inside the window is returned **unchanged**. Adding jitter
 * to an already-sendable step would push it later on every 5-minute cron tick,
 * and a step could drift for hours without ever being sent; returning it as-is
 * makes the function idempotent, which is what "never rescheduled twice" means
 * in practice.
 *
 * @throws if the window config is invalid, or if the holiday list blocks more
 *   than {@link MAX_HOLIDAY_LOOKAHEAD_DAYS} consecutive days.
 */
export function nextSendWindow(
	date: Date,
	config: QuietHoursConfig,
	jitter: JitterFn = defaultJitter,
): Date {
	assertWindow(config);
	if (!isQuietHours(date, config)) return date;
 
	const ms = date.getTime();
	const { hour } = istParts(ms);
	const today = istDayStartMs(ms);
 
	// Before the window opens → today. At or after it → tomorrow. A step landing
	// mid-window can only be here because today is a holiday, and today's 10:00
	// is already behind it, so tomorrow is right for that case too.
	let dayStart = hour < config.startHour ? today : today + DAY_MS;
 
	let skipped = 0;
	while (config.holidays.includes(istParts(dayStart).dateISO)) {
		dayStart += DAY_MS;
		skipped += 1;
		if (skipped > MAX_HOLIDAY_LOOKAHEAD_DAYS) {
			throw new Error(
				`No sendable day within ${MAX_HOLIDAY_LOOKAHEAD_DAYS} days of ${istParts(ms).dateISO} IST: check the holiday list`,
			);
		}
	}
 
	return new Date(
		dayStart + config.startHour * HOUR_MS + clampJitter(jitter(), config),
	);
}
 
/**
 * How many more sends the daily cap allows.
 *
 * A count, not a boolean, because the scheduler releases tranches and needs the
 * headroom to size the batch; `remainingDailySends(...) === 0` is the boolean
 * at the call site. Clamped at zero so a cap lowered mid-day (or a send that
 * slipped past the cap) yields "send nothing" rather than a negative number
 * that a caller might hand to a loop bound or a SQL LIMIT.
 */
export function remainingDailySends(sentToday: number, cap: number): number {
	if (!Number.isFinite(sentToday) || sentToday < 0) {
		throw new Error(
			`Invalid daily send count ${sentToday}: expected a non-negative number`,
		);
	}
	if (!Number.isFinite(cap) || cap < 0) {
		throw new Error(`Invalid daily cap ${cap}: expected a non-negative number`);
	}
	return Math.max(0, cap - sentToday);
}