1
/**2
* Strict Schedule decoding, replay, time validation, and framing.3
* @module @deepseek-ai/dsh-schedule4
*/6
import { Temporal } from '@js-temporal/polyfill'7
import { SessionLogOffset } from '@deepseek-ai/dsh-session'8
import type { SessionEvent, SessionLogOffset as SessionLogOffsetType } from '@deepseek-ai/dsh-session'9
import type {10
AfterScheduleRecord,11
AtInput,12
AtScheduleRecord,13
EveryScheduleRecord,14
CronInput,15
CronScheduleRecord,16
DailyInput,17
DailyScheduleRecord,18
WeeklyInput,19
WeeklyScheduleRecord,20
LegacyAfterScheduleRecord,21
LegacyAtScheduleRecord,22
LegacyEveryScheduleRecord,23
LegacyScheduleRecord,24
RecurringScheduleRecord,25
LocalAtInput,26
OneShotScheduleRecord,27
ScheduleChange,28
ScheduleId as ScheduleIdType,29
ScheduleRecord,30
ScheduleView,31
} from './types.ts'33
/** Durable Schedule protocol version implemented by this package. */34
export const SCHEDULE_CHANGE_VERSION = 1 as const36
/** Fixed v1 lower bound for a fixed-rate reminder. */37
export const MIN_EVERY_INTERVAL_SECONDS = 6039
/** Fixed v1 upper bound for a stored task title. */40
export const MAX_TITLE_LENGTH = 12042
/**43
* Longest forward or backward walk of the cron date search, in years.44
*45
* The proleptic Gregorian leap-year and weekday alignment repeats every 40046
* years, so a rule that matches any local date has a match within this window;47
* walking further can only reach a rule that never matches. Bounding the walk48
* keeps a valid but unsatisfiable rule's creation and decision cost fixed49
* instead of enumerating candidate dates toward the four-digit-year ceiling.50
*/51
export const CRON_SEARCH_HORIZON_YEARS = 40053
const MIN_FOUR_DIGIT_YEAR_MS = Date.parse('0001-01-01T00:00:00.000Z')54
const MAX_FOUR_DIGIT_YEAR_MS = Date.parse('9999-12-31T23:59:59.999Z')55
const UTC_INSTANT = /^(?!0000)\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d\.\d{3}Z$/56
const OFFSET_INSTANT = new RegExp(57
String.raw`^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})`58
+ String.raw`T(?<hour>\d{2}):(?<minute>\d{2}):(?<second>\d{2})`59
+ String.raw`(?:\.(?<fraction>\d{1,3}))?(?<zone>Z|(?<sign>[+-])`60
+ String.raw`(?<offsetHour>\d{2}):(?<offsetMinute>\d{2}))$`,61
)62
const LOCAL_DATE = /^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})$/63
const LOCAL_TIME = /^(?<hour>\d{2}):(?<minute>\d{2}):(?<second>\d{2})(?:\.(?<fraction>\d{1,3}))?$/64
const LOCAL_CLOCK_TIME = /^(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{1,3})?$/65
const IANA_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/67
/** Error from malformed or transition-invalid durable Schedule data. */68
export class ScheduleLogError extends Error {69
/** Stable machine-readable error code. */70
readonly code = 'corrupt_schedule_log' as const72
/**73
* Construct a durable-log failure.74
* @param message - Package-specific violated invariant.75
*/76
constructor(message: string) {77
super(message)78
this.name = 'ScheduleLogError'79
}80
}82
/** Error from a model-supplied Schedule rule or target Session that cannot become a record. */83
export class ScheduleInputError extends Error {84
/** Stable public Schedule input code. */85
readonly code:86
| 'invalid_prompt'87
| 'invalid_selector'88
| 'invalid_rule'89
| 'invalid_time_zone'90
| 'not_future'91
| 'time_out_of_range'92
| 'frequency_too_high'93
| 'subagent_session'95
/**96
* Construct a stable input failure.97
* @param code - Public Schedule error discriminator.98
* @param message - Stable public diagnostic.99
* @param options - Optional contained implementation cause.100
*/101
constructor(102
code:103
| 'invalid_prompt'104
| 'invalid_selector'105
| 'invalid_rule'106
| 'invalid_time_zone'107
| 'not_future'108
| 'time_out_of_range'109
| 'frequency_too_high'110
| 'subagent_session',111
message: string,112
options?: ErrorOptions,113
) {114
super(message, options)115
this.name = 'ScheduleInputError'116
this.code = code117
}118
}120
/** Pure replay result, retaining active create order and every used id. */121
export interface FoldedSchedules {122
/** Active records in their original create order. */123
readonly active: readonly LegacyScheduleRecord[]124
/** Every id ever created in this session-local suffix. */125
readonly seenIds: readonly ScheduleIdType[]126
}128
/** One latest-only recurring decision derived without enumerating a backlog. */129
export interface RecurringOccurrence {130
/** Latest occurrence due at the decision time. */131
readonly occurrenceAt: string132
/** First eligible target after the decision, or exhaustion. */133
readonly nextScheduledAt?: string134
}136
/**137
* Brand a raw session-local id without changing its runtime value.138
* @param value - Raw session-local id.139
* @returns The same string with the Schedule brand.140
*/141
export function ScheduleId(value: string): ScheduleIdType {142
return value as ScheduleIdType143
}145
/** Whether an unknown value is a non-array object. */146
function isRecord(value: unknown): value is Record<string, unknown> {147
return typeof value === 'object' && value !== null && !Array.isArray(value)148
}150
/** Require exactly the named durable object keys. */151
function hasExactKeys(value: Record<string, unknown>, expected: readonly string[]): boolean {152
const allowed = new Set(expected)153
return expected.every(key => key in value) && Object.keys(value).every(key => allowed.has(key))154
}156
/** Require the named durable keys while admitting further optional members. */157
function hasExactKeysWithOptional(158
value: Record<string, unknown>,159
required: readonly string[],160
optional: readonly string[],161
): boolean {162
const allowed = new Set([...required, ...optional])163
return required.every(key => key in value) && Object.keys(value).every(key => allowed.has(key))164
}166
/** Stable diagnostic for a title that is missing or empty after trimming. */167
export const REQUIRED_TITLE_MESSAGE = 'title is required and must be non-empty after trimming.'169
/**170
* Validate the title supplied at creation.171
*172
* Creation requires an explicit title: a missing, blank-after-trim, or over-long173
* value throws instead of deriving a name from the instruction.174
* @param title - Task name supplied at creation.175
* @returns The trimmed title; an invalid title throws ScheduleInputError.176
*/177
export function scheduleTitle(title: string): string {178
if (typeof title !== 'string' || title.trim().length === 0) {179
throw new ScheduleInputError('invalid_prompt', REQUIRED_TITLE_MESSAGE)180
}181
const normalized = title.trim()182
if (normalized.length > MAX_TITLE_LENGTH) {183
throw new ScheduleInputError('invalid_prompt', `title must be at most ${MAX_TITLE_LENGTH} characters.`)184
}185
return normalized186
}188
/**189
* Validate one required stored title at the durable boundary.190
*191
* Only records written after titles became required are read: a missing,192
* blank-after-trim, untrimmed, or over-long stored title is invalid, and no name193
* is derived from the instruction.194
* @param value - Untrusted durable title field.195
* @returns The stored title; an invalid title throws ScheduleLogError.196
*/197
export function decodeStoredTitle(value: unknown): string {198
if (typeof value !== 'string' || value.trim().length === 0) {199
throw new ScheduleLogError(REQUIRED_TITLE_MESSAGE)200
}201
if (value.length > MAX_TITLE_LENGTH) {202
throw new ScheduleLogError(`title must be at most ${MAX_TITLE_LENGTH} characters`)203
}204
if (value.trim() !== value) {205
throw new ScheduleLogError('title must be a trimmed string')206
}207
return value208
}210
/**211
* Decode the required stored title of one durable Host record, then require its exact key set.212
*213
* The title decodes first so that a record without the key reports the title214
* diagnostic rather than the record's required-key list.215
* @param value - Untrusted durable record already known to be an object.216
* @param expected - Exact keys the record must carry, including `title`.217
* @param message - Diagnostic naming the record's required keys.218
* @returns The stored title; an invalid title or key set throws ScheduleLogError.219
*/220
function decodeRecordTitle(value: Record<string, unknown>, expected: readonly string[], message: string): string {221
const title = decodeStoredTitle(value['title'])222
if (!hasExactKeys(value, expected)) throw new ScheduleLogError(message)223
return title224
}226
/**227
* Decode the stored title of one historical `schedule/change` create record.228
*229
* A version-1 event written before names existed has no `title` member, so the230
* key set is required without it and the absent member decodes as undefined. A231
* present title stays subject to the canonical stored form.232
* @param value - Untrusted durable record already known to be an object.233
* @param expected - Exact keys the record may carry; `title` is optional within them.234
* @param message - Diagnostic naming the record's keys.235
* @returns The stored title, or undefined when the historical record predates it.236
*/237
function decodeHistoricalRecordTitle(238
value: Record<string, unknown>,239
expected: readonly string[],240
message: string,241
): string | undefined {242
const keys = expected.filter(key => key !== 'title')243
if (!hasExactKeysWithOptional(value, keys, ['title'])) throw new ScheduleLogError(message)244
return value['title'] === undefined ? undefined : decodeStoredTitle(value['title'])245
}247
/** Validate one stable session-local id at the durable boundary. */248
function decodeId(value: unknown): ScheduleIdType {249
if (typeof value !== 'string' || value.length === 0 || value.trim() !== value) {250
throw new ScheduleLogError('schedule id must be a non-empty string without surrounding whitespace')251
}252
return ScheduleId(value)253
}255
/** Validate one canonical four-digit-year UTC instant. */256
function decodeInstant(value: unknown): string {257
if (typeof value !== 'string' || !UTC_INSTANT.test(value)) {258
throw new ScheduleLogError('scheduledAt must be a canonical four-digit-year RFC 3339 UTC instant')259
}260
const epoch = Date.parse(value)261
if (!Number.isFinite(epoch) || new Date(epoch).toISOString() !== value) {262
throw new ScheduleLogError('scheduledAt is not a real UTC calendar instant')263
}264
return value265
}267
interface CalendarParts {268
readonly year: number269
readonly month: number270
readonly day: number271
readonly hour: number272
readonly minute: number273
readonly second: number274
readonly millisecond: number275
}277
/** Read one required named regular-expression group as a number. */278
function groupNumber(groups: Record<string, string | undefined>, name: string): number {279
const value = groups[name]280
/* v8 ignore next -- successful fixed regexes always provide every requested group. */281
if (value === undefined) throw new ScheduleInputError('invalid_rule', 'The at value has an invalid shape.')282
return Number(value)283
}285
/** Convert exact calendar fields to a UTC-shaped epoch while rejecting normalization. */286
function calendarEpoch(parts: CalendarParts): number {287
const value = new Date(0)288
value.setUTCHours(0, 0, 0, 0)289
value.setUTCFullYear(parts.year, parts.month - 1, parts.day)290
value.setUTCHours(parts.hour, parts.minute, parts.second, parts.millisecond)291
const epoch = value.getTime()292
if (!Number.isFinite(epoch)293
|| value.getUTCFullYear() !== parts.year294
|| value.getUTCMonth() + 1 !== parts.month295
|| value.getUTCDate() !== parts.day296
|| value.getUTCHours() !== parts.hour297
|| value.getUTCMinutes() !== parts.minute298
|| value.getUTCSeconds() !== parts.second299
|| value.getUTCMilliseconds() !== parts.millisecond) {300
throw new ScheduleInputError('invalid_rule', 'The at value must be a real ISO calendar date and time.')301
}302
return epoch303
}305
/** Normalize an optional one-to-three digit fractional second to milliseconds. */306
function milliseconds(value: string | undefined): number {307
return value === undefined ? 0 : Number(value.padEnd(3, '0'))308
}310
/** Require a safe, representable, strictly future UTC target. */311
function futureInstant(epoch: number, now: number): string {312
if (!Number.isSafeInteger(now) || !Number.isSafeInteger(epoch)313
|| epoch < MIN_FOUR_DIGIT_YEAR_MS || epoch > MAX_FOUR_DIGIT_YEAR_MS) {314
throw new ScheduleInputError(315
'time_out_of_range',316
'The scheduled time must be representable as a four-digit-year RFC 3339 UTC instant.',317
)318
}319
if (epoch <= now) {320
throw new ScheduleInputError('not_future', 'The scheduled time must be strictly in the future.')321
}322
const instant = new Date(epoch).toISOString()323
/* v8 ignore next -- an in-range integral Date always formats as the canonical UTC profile. */324
if (!UTC_INSTANT.test(instant)) {325
throw new ScheduleInputError(326
'time_out_of_range',327
'The scheduled time must be representable as a four-digit-year RFC 3339 UTC instant.',328
)329
}330
return instant331
}333
/** Parse a strict RFC 3339 instant whose numeric offset is part of the input. */334
function parseOffsetInstant(value: string): number {335
const match = OFFSET_INSTANT.exec(value)336
const groups = match?.groups337
if (groups === undefined) {338
throw new ScheduleInputError(339
'invalid_rule',340
'at must use YYYY-MM-DDTHH:mm:ss with optional 1-3 digit fractional seconds and an explicit Z or numeric offset.',341
)342
}343
const parts: CalendarParts = {344
year: groupNumber(groups, 'year'),345
month: groupNumber(groups, 'month'),346
day: groupNumber(groups, 'day'),347
hour: groupNumber(groups, 'hour'),348
minute: groupNumber(groups, 'minute'),349
second: groupNumber(groups, 'second'),350
millisecond: milliseconds(groups['fraction']),351
}352
if (parts.year === 0 || parts.hour > 23 || parts.minute > 59 || parts.second > 59) {353
throw new ScheduleInputError('invalid_rule', 'The at value must be a real ISO calendar date and time.')354
}355
const localEpoch = calendarEpoch(parts)356
if (groups['zone'] === 'Z') return localEpoch357
const offsetHour = groupNumber(groups, 'offsetHour')358
const offsetMinute = groupNumber(groups, 'offsetMinute')359
if (offsetHour > 23 || offsetMinute > 59360
|| (groups['sign'] === '-' && offsetHour === 0 && offsetMinute === 0)) {361
throw new ScheduleInputError('invalid_rule', 'The at numeric offset is invalid.')362
}363
const direction = groups['sign'] === '+' ? 1 : -1364
return localEpoch - direction * (offsetHour * 60 + offsetMinute) * 60_000365
}367
/**368
* Validate and canonicalize one raw IANA time-zone selector.369
* @param value - Candidate `UTC` or IANA Area/Location name.370
* @returns The runtime's canonical IANA name.371
*/372
export function canonicalizeTimeZone(value: string): string {373
if (value.length === 0 || value.trim() !== value || (value !== 'UTC' && !IANA_ZONE.test(value))) {374
throw new ScheduleInputError('invalid_time_zone', 'time_zone must be UTC or a valid IANA Area/Location name.')375
}376
let canonical: string377
try {378
canonical = new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone379
} catch (error: unknown) {380
throw new ScheduleInputError(381
'invalid_time_zone',382
'time_zone must be UTC or a valid IANA Area/Location name.',383
{ cause: error },384
)385
}386
/* v8 ignore next -- Intl returns the requested canonical zone or an IANA canonical alias. */387
if (canonical !== 'UTC' && !IANA_ZONE.test(canonical)) {388
throw new ScheduleInputError('invalid_time_zone', 'time_zone must resolve to UTC or an IANA Area/Location name.')389
}390
return canonical391
}393
/** Parse strict local calendar fields without consulting a process time zone. */394
function parseLocalAt(value: LocalAtInput): CalendarParts {395
const dateMatch = LOCAL_DATE.exec(value.date)396
const timeMatch = LOCAL_TIME.exec(value.time)397
const date = dateMatch?.groups398
const time = timeMatch?.groups399
if (date === undefined || time === undefined) {400
throw new ScheduleInputError(401
'invalid_rule',402
'Local at requires date YYYY-MM-DD and time HH:mm:ss with optional one-to-three digit milliseconds.',403
)404
}405
const parts: CalendarParts = {406
year: groupNumber(date, 'year'),407
month: groupNumber(date, 'month'),408
day: groupNumber(date, 'day'),409
hour: groupNumber(time, 'hour'),410
minute: groupNumber(time, 'minute'),411
second: groupNumber(time, 'second'),412
millisecond: milliseconds(time['fraction']),413
}414
if (parts.year === 0 || parts.hour > 23 || parts.minute > 59 || parts.second > 59) {415
throw new ScheduleInputError('invalid_rule', 'The local at value must be a real ISO calendar date and time.')416
}417
calendarEpoch(parts)418
return parts419
}421
/** Resolve only the earlier overlap instant; a gap fails the local-field round trip. */422
function localInstant(local: Temporal.PlainDateTime, timeZone: string): number | undefined {423
const zoned = local.toZonedDateTime(timeZone, { disambiguation: 'earlier' })424
return zoned.toPlainDateTime().equals(local) ? zoned.epochMilliseconds : undefined425
}427
/** Resolve a local one-shot, rejecting nonexistent wall-clock times. */428
function resolveLocalInstant(parts: CalendarParts, timeZone: string): number {429
const target = localInstant(Temporal.PlainDateTime.from(parts, { overflow: 'reject' }), timeZone)430
if (target === undefined) {431
throw new ScheduleInputError('invalid_rule', 'The local at time does not exist in the selected time zone.')432
}433
return target434
}436
/** Decode the exact v1 after record shape. */437
function decodeAfterRecord(value: unknown): LegacyAfterScheduleRecord {438
/* v8 ignore next -- decodeLegacyScheduleRecord rejects a non-object before it dispatches here. */439
if (!isRecord(value)) throw new ScheduleLogError('after schedule must be an object')440
const title = decodeHistoricalRecordTitle(441
value,442
['id', 'kind', 'title', 'prompt', 'afterSeconds', 'scheduledAt'],443
'after schedule must contain exactly id, kind, title, prompt, afterSeconds, and scheduledAt',444
)445
const prompt = value['prompt']446
if (typeof prompt !== 'string' || prompt.length === 0 || prompt.trim() !== prompt) {447
throw new ScheduleLogError('after prompt must be non-empty and already trimmed')448
}449
const afterSeconds = value['afterSeconds']450
if (!Number.isSafeInteger(afterSeconds) || (afterSeconds as number) <= 0) {451
throw new ScheduleLogError('afterSeconds must be a positive safe integer')452
}453
return Object.freeze({454
id: decodeId(value['id']),455
kind: 'after',456
...(title === undefined ? {} : { title }),457
prompt,458
afterSeconds: afterSeconds as number,459
scheduledAt: decodeInstant(value['scheduledAt']),460
})461
}463
/** Decode the exact v1 absolute one-shot record shape. */464
function decodeAtRecord(value: unknown): LegacyAtScheduleRecord {465
/* v8 ignore next -- decodeLegacyScheduleRecord rejects a non-object before it dispatches here. */466
if (!isRecord(value)) throw new ScheduleLogError('at schedule must be an object')467
const title = decodeHistoricalRecordTitle(468
value,469
['id', 'kind', 'title', 'prompt', 'scheduledAt'],470
'at schedule must contain exactly id, kind, title, prompt, and scheduledAt',471
)472
const prompt = value['prompt']473
if (typeof prompt !== 'string' || prompt.length === 0 || prompt.trim() !== prompt) {474
throw new ScheduleLogError('at prompt must be non-empty and already trimmed')475
}476
return Object.freeze({477
id: decodeId(value['id']),478
kind: 'at',479
...(title === undefined ? {} : { title }),480
prompt,481
scheduledAt: decodeInstant(value['scheduledAt']),482
})483
}485
/** Decode the exact v1 fixed-rate record shape. */486
function decodeEveryRecord(value: unknown): LegacyEveryScheduleRecord {487
/* v8 ignore next -- decodeLegacyScheduleRecord rejects a non-object before it dispatches here. */488
if (!isRecord(value)) throw new ScheduleLogError('every schedule must be an object')489
const title = decodeHistoricalRecordTitle(490
value,491
['id', 'kind', 'title', 'prompt', 'everySeconds', 'scheduledAt'],492
'every schedule must contain exactly id, kind, title, prompt, everySeconds, and scheduledAt',493
)494
const prompt = value['prompt']495
if (typeof prompt !== 'string' || prompt.length === 0 || prompt.trim() !== prompt) {496
throw new ScheduleLogError('every prompt must be non-empty and already trimmed')497
}498
const everySeconds = value['everySeconds']499
const interval = typeof everySeconds === 'number' ? everySeconds * 1_000 : Number.NaN500
if (!Number.isSafeInteger(everySeconds)501
|| (everySeconds as number) < MIN_EVERY_INTERVAL_SECONDS502
|| !Number.isSafeInteger(interval)) {503
throw new ScheduleLogError(`everySeconds must be a safe integer of at least ${MIN_EVERY_INTERVAL_SECONDS}`)504
}505
return Object.freeze({506
id: decodeId(value['id']),507
kind: 'every',508
...(title === undefined ? {} : { title }),509
prompt,510
everySeconds: everySeconds as number,511
scheduledAt: decodeInstant(value['scheduledAt']),512
})513
}515
/**516
* Parse one strict local clock time shared by the wall-clock selectors.517
* @param value - Candidate `HH:mm:ss` time with optional one-to-three fractional digits.518
* @param selector - Public selector name used in the diagnostic.519
* @returns The parsed plain time; malformed input throws ScheduleInputError.520
*/521
function localClockTime(value: string, selector: string): Temporal.PlainTime {522
if (!LOCAL_CLOCK_TIME.test(value)) {523
throw new ScheduleInputError(524
'invalid_rule',525
`${selector}.time must use HH:mm:ss with optional 1-3 fractional digits, without leap seconds or 24:00.`,526
)527
}528
return Temporal.PlainTime.from(value)529
}531
/** Parse strictly before Temporal can constrain or coerce the supplied time. */532
function dailyTime(value: string): Temporal.PlainTime {533
return localClockTime(value, 'daily')534
}536
/**537
* Parse one strict local clock time accepted by the weekly selector.538
* @param value - Candidate `HH:mm:ss` time with optional one-to-three fractional digits.539
* @returns The parsed plain time; malformed input throws ScheduleInputError.540
*/541
export function weeklyTime(value: string): Temporal.PlainTime {542
return localClockTime(value, 'weekly')543
}545
/** Fail already-typed durable values without wrapping the stored-data diagnostic. */546
function rethrowLogError(error: unknown): never {547
/* v8 ignore next -- every wrapped call raises ScheduleInputError, so this pass-through never fires. */548
if (error instanceof ScheduleLogError) throw error549
throw new ScheduleLogError(String(error))550
}552
/** Decode a Host daily rule without re-resolving its committed UTC target. */553
function decodeDailyRecord(value: Record<string, unknown>): DailyScheduleRecord {554
const title = decodeRecordTitle(555
value,556
['id', 'kind', 'title', 'prompt', 'time', 'timeZone', 'scheduledAt'],557
'daily schedule must contain exactly id, kind, title, prompt, time, timeZone, and scheduledAt',558
)559
const prompt = value['prompt']560
if (typeof prompt !== 'string' || prompt.length === 0 || prompt.trim() !== prompt) {561
throw new ScheduleLogError('daily prompt must be non-empty and already trimmed')562
}563
const time = value['time']564
const timeZone = value['timeZone']565
if (typeof time !== 'string' || typeof timeZone !== 'string') {566
throw new ScheduleLogError('daily time and timeZone must be strings')567
}568
let normalized: string569
try {570
normalized = dailyTime(time).toString({ fractionalSecondDigits: 3 })571
canonicalizeTimeZone(timeZone)572
} catch (error: unknown) {573
rethrowLogError(error)574
}575
if (time !== normalized) throw new ScheduleLogError('daily time must be normalized to HH:mm:ss.SSS')576
return Object.freeze({577
id: decodeId(value['id']), kind: 'daily', title, prompt, time, timeZone,578
scheduledAt: decodeInstant(value['scheduledAt']),579
})580
}582
/**583
* Normalize the explicit ISO weekday set of one weekly rule.584
* @param weekdays - Untrusted candidate weekday values.585
* @returns Frozen unique ascending weekdays from 1 (Monday) through 7 (Sunday).586
*/587
export function normalizeWeekdays(weekdays: unknown): number[] {588
if (!Array.isArray(weekdays) || weekdays.length === 0) {589
throw new ScheduleInputError('invalid_rule', 'weekly.weekdays must be a non-empty array of ISO weekday numbers.')590
}591
const unique = new Set<number>()592
for (const weekday of weekdays as unknown[]) {593
if (typeof weekday !== 'number' || !Number.isInteger(weekday) || weekday < 1 || weekday > 7) {594
throw new ScheduleInputError(595
'invalid_rule', 'Each weekly.weekdays entry must be an integer from 1 (Monday) through 7 (Sunday).',596
)597
}598
if (unique.has(weekday)) {599
throw new ScheduleInputError('invalid_rule', `weekly.weekdays must not repeat weekday ${weekday}.`)600
}601
unique.add(weekday)602
}603
const ordered = [...unique].sort((left, right) => left - right)604
Object.freeze(ordered)605
return ordered606
}607
/**608
* Decode a Host weekly rule without re-resolving its committed UTC target.609
* @param value - Untrusted durable record already identified as weekly.610
* @returns Detached frozen weekly record; malformed fields throw ScheduleLogError.611
*/612
function decodeWeeklyRecord(value: Record<string, unknown>): WeeklyScheduleRecord {613
const title = decodeRecordTitle(614
value,615
['id', 'kind', 'title', 'prompt', 'time', 'timeZone', 'weekdays', 'scheduledAt'],616
'weekly schedule must contain exactly id, kind, title, prompt, time, timeZone, weekdays, and scheduledAt',617
)618
const prompt = value['prompt']619
if (typeof prompt !== 'string' || prompt.length === 0 || prompt.trim() !== prompt) {620
throw new ScheduleLogError('weekly prompt must be non-empty and already trimmed')621
}622
const time = value['time']623
const timeZone = value['timeZone']624
if (typeof time !== 'string' || typeof timeZone !== 'string') {625
throw new ScheduleLogError('weekly time and timeZone must be strings')626
}627
try {628
canonicalizeTimeZone(timeZone)629
} catch (error: unknown) {630
rethrowLogError(error)631
}632
let normalized: string633
try {634
normalized = weeklyTime(time).toString({ fractionalSecondDigits: 3 })635
} catch (error: unknown) {636
rethrowLogError(error)637
}638
if (time !== normalized) throw new ScheduleLogError('weekly time must be normalized to HH:mm:ss.SSS')639
// normalizeWeekdays proves the field is a number array before this check compares it to the decoded copy.640
const stored = value['weekdays'] as number[]641
let weekdays: number[]642
try {643
weekdays = normalizeWeekdays(stored)644
} catch (error: unknown) {645
rethrowLogError(error)646
}647
if (weekdays.some((weekday, index) => weekday !== stored[index])) {648
throw new ScheduleLogError('weekly weekdays must be normalized to unique ascending ISO weekday numbers')649
}650
return Object.freeze({651
id: decodeId(value['id']), kind: 'weekly', title, prompt, time, timeZone,652
weekdays, scheduledAt: decodeInstant(value['scheduledAt']),653
})654
}656
/**657
* Decode a Host cron rule without re-resolving its committed UTC target.658
* @param value - Untrusted durable record already identified as cron.659
* @returns Detached frozen cron record; malformed fields throw ScheduleLogError.660
*/661
function decodeCronRecord(value: Record<string, unknown>): CronScheduleRecord {662
const title = decodeRecordTitle(663
value,664
['id', 'kind', 'title', 'prompt', 'expression', 'timeZone', 'scheduledAt'],665
'cron schedule must contain exactly id, kind, title, prompt, expression, timeZone, and scheduledAt',666
)667
const prompt = value['prompt']668
if (typeof prompt !== 'string' || prompt.length === 0 || prompt.trim() !== prompt) {669
throw new ScheduleLogError('cron prompt must be non-empty and already trimmed')670
}671
const expression = value['expression']672
const timeZone = value['timeZone']673
if (typeof expression !== 'string' || typeof timeZone !== 'string') {674
throw new ScheduleLogError('cron expression and timeZone must be strings')675
}676
try {677
canonicalizeTimeZone(timeZone)678
} catch (error: unknown) {679
rethrowLogError(error)680
}681
let canonical: string682
try {683
canonical = parseCronExpression(expression).expression684
} catch (error: unknown) {685
rethrowLogError(error)686
}687
if (expression !== canonical) throw new ScheduleLogError('cron expression must be canonical')688
return Object.freeze({689
id: decodeId(value['id']), kind: 'cron', title, prompt, expression, timeZone,690
scheduledAt: decodeInstant(value['scheduledAt']),691
})692
}694
/**695
* Decode a current Host task record, preserving its committed target and stored zone spelling.696
*697
* Every variant of a stored Host task carries its title, so the historical698
* one-shot variants are re-checked for that member after their shape decodes.699
* @param value - Untrusted durable JSON record.700
* @returns Detached frozen record; malformed fields throw ScheduleLogError.701
*/702
export function decodeScheduleRecord(value: unknown): ScheduleRecord {703
if (isRecord(value) && value['kind'] === 'daily') return decodeDailyRecord(value)704
if (isRecord(value) && value['kind'] === 'weekly') return decodeWeeklyRecord(value)705
if (isRecord(value) && value['kind'] === 'cron') return decodeCronRecord(value)706
const record = decodeLegacyScheduleRecord(value)707
if (record.title === undefined) throw new ScheduleLogError(REQUIRED_TITLE_MESSAGE)708
return record as ScheduleRecord709
}711
/**712
* Decode only the variants admitted by historical version-1 Session events.713
*714
* A record written before titles existed decodes without that member, so the715
* fold keeps reading a log that the current creation path could not write.716
* @param value - Untrusted durable JSON record from a Session event.717
* @returns Detached frozen record, without a title when the event omitted it.718
*/719
function decodeLegacyScheduleRecord(value: unknown): LegacyScheduleRecord {720
if (!isRecord(value)) throw new ScheduleLogError('schedule record must be an object')721
switch (value['kind']) {722
case 'after': return decodeAfterRecord(value)723
case 'at': return decodeAtRecord(value)724
case 'every': return decodeEveryRecord(value)725
default: throw new ScheduleLogError('v1 schedule kind must be "after", "at", or "every"')726
}727
}729
/**730
* Decode one strict version-1 `schedule/change` payload.731
* @param value - Untrusted durable JSON value.732
* @returns Detached, frozen Schedule change.733
*/734
export function decodeScheduleChange(value: unknown): ScheduleChange {735
if (!isRecord(value)) throw new ScheduleLogError('schedule/change payload must be an object')736
if (value['version'] !== SCHEDULE_CHANGE_VERSION) {737
throw new ScheduleLogError('schedule/change version must be 1')738
}739
switch (value['operation']) {740
case 'create':741
if (!hasExactKeys(value, ['version', 'operation', 'schedule'])) {742
throw new ScheduleLogError('schedule create must contain exactly version, operation, and schedule')743
}744
return Object.freeze({745
version: SCHEDULE_CHANGE_VERSION,746
operation: 'create',747
schedule: decodeLegacyScheduleRecord(value['schedule']),748
})749
case 'delete': {750
if (!hasExactKeys(value, ['version', 'operation', 'id'])) {751
throw new ScheduleLogError('schedule delete must contain exactly version, operation, and id')752
}753
return Object.freeze({754
version: SCHEDULE_CHANGE_VERSION,755
operation: 'delete',756
id: decodeId(value['id']),757
})758
}759
case 'dispatch': {760
if (hasExactKeys(value, ['version', 'operation', 'id'])) {761
return Object.freeze({762
version: SCHEDULE_CHANGE_VERSION,763
operation: 'dispatch',764
id: decodeId(value['id']),765
})766
}767
if (hasExactKeys(value, ['version', 'operation', 'id', 'acceptedAt'])) {768
return Object.freeze({769
version: SCHEDULE_CHANGE_VERSION,770
operation: 'dispatch',771
id: decodeId(value['id']),772
acceptedAt: decodeInstant(value['acceptedAt']),773
})774
}775
throw new ScheduleLogError('schedule dispatch must contain id and optional acceptedAt only')776
}777
default:778
throw new ScheduleLogError('schedule/change operation must be create, delete, or dispatch')779
}780
}782
/** Timing fields one fixed-rate decision needs from its record. */783
type EveryOccurrenceInput = Pick<EveryScheduleRecord, 'everySeconds' | 'scheduledAt'>785
/**786
* Resolve one fixed-rate decision without enumerating missed occurrences.787
* @param record - Active record whose target is the earliest unaccepted occurrence.788
* @param acceptedAt - Wall-clock decision time in epoch milliseconds.789
* @returns The latest due occurrence and first strictly future target, if representable.790
*/791
export function resolveEveryOccurrence(792
record: EveryOccurrenceInput,793
acceptedAt: number,794
): RecurringOccurrence {795
const target = Date.parse(record.scheduledAt)796
const interval = record.everySeconds * 1_000797
if (!Number.isSafeInteger(acceptedAt)798
|| acceptedAt < MIN_FOUR_DIGIT_YEAR_MS799
|| acceptedAt > MAX_FOUR_DIGIT_YEAR_MS) {800
throw new ScheduleLogError('every acceptedAt must be a representable four-digit-year instant')801
}802
if (!Number.isSafeInteger(interval) || interval <= 0) {803
throw new ScheduleLogError('every interval milliseconds must be a positive safe integer')804
}805
if (acceptedAt < target) {806
throw new ScheduleLogError('every dispatch cannot precede the active scheduledAt')807
}808
const steps = Math.floor((acceptedAt - target) / interval)809
const occurrence = target + steps * interval810
/* v8 ignore next -- bounded operands and a quotient-derived product stay safe. */811
if (!Number.isSafeInteger(occurrence) || occurrence < target || occurrence > acceptedAt) {812
throw new ScheduleLogError('every occurrence arithmetic must stay within the accepted interval')813
}814
const occurrenceAt = new Date(occurrence).toISOString()815
const next = occurrence + interval816
if (!Number.isSafeInteger(next) || next > MAX_FOUR_DIGIT_YEAR_MS) {817
return Object.freeze({ occurrenceAt })818
}819
return Object.freeze({820
occurrenceAt,821
nextScheduledAt: new Date(next).toISOString(),822
})823
}825
/** Project an explicit instant into a calendar date in the rule's zone. */826
function localDate(epoch: number, timeZone: string): Temporal.PlainDate {827
return Temporal.Instant.fromEpochMilliseconds(epoch).toZonedDateTimeISO(timeZone).toPlainDate()828
}830
/** Recognize one of the rule's explicit ISO weekdays on a local calendar date. */831
type WeekdayMatch = (date: Temporal.PlainDate) => boolean833
/** Skip every local date; used by a rule that selects by time alone. */834
const EVERY_DATE: WeekdayMatch = () => true836
/** Select the explicit ISO weekdays of one normalized weekly rule. */837
function weekdaySet(weekdays: readonly number[]): WeekdayMatch {838
const selected = new Set(weekdays)839
return date => selected.has(date.dayOfWeek)840
}842
/** Find the first actual occurrence after now and, when supplied, after a delivered date. */843
function nextMatchingTarget(844
time: Temporal.PlainTime,845
timeZone: string,846
now: number,847
matches: WeekdayMatch,848
afterDate?: Temporal.PlainDate,849
): string | undefined {850
let date = localDate(now, timeZone)851
const lastDate = localDate(MAX_FOUR_DIGIT_YEAR_MS, 'UTC').add({ days: 1 })852
if (afterDate !== undefined && Temporal.PlainDate.compare(date, afterDate) <= 0) {853
date = afterDate.add({ days: 1 })854
}855
for (; Temporal.PlainDate.compare(date, lastDate) <= 0; date = date.add({ days: 1 })) {856
if (!matches(date)) continue857
const target = localInstant(date.toPlainDateTime(time), timeZone)858
if (target === undefined) continue859
if (target > MAX_FOUR_DIGIT_YEAR_MS) return undefined860
if (target >= MIN_FOUR_DIGIT_YEAR_MS && target > now) return new Date(target).toISOString()861
}862
return undefined863
}865
/** Find the latest actual occurrence at or before a decision without leaving the committed floor. */866
function latestDueOccurrence(867
timeZone: string,868
matches: WeekdayMatch,869
time: Temporal.PlainTime,870
acceptedAt: number,871
savedTarget: number,872
): number {873
// Offsets are less than 24 hours; a date-line rollback can make the current local date earlier than a due date.874
let date = localDate(acceptedAt, 'UTC').add({ days: 1 })875
for (;; date = date.subtract({ days: 1 })) {876
if (!matches(date)) continue877
const candidate = localInstant(date.toPlainDateTime(time), timeZone)878
if (candidate === undefined || candidate > acceptedAt) continue879
// A committed target remains due even if current tzdata places the rule before it.880
return Math.max(candidate, savedTarget)881
}882
}884
/** Resolve a wall-clock rule only when its decision is at a representable four-digit-year instant. */885
function acceptedDecision(selector: string, acceptedAt: number): number {886
if (!Number.isSafeInteger(acceptedAt)887
|| acceptedAt < MIN_FOUR_DIGIT_YEAR_MS || acceptedAt > MAX_FOUR_DIGIT_YEAR_MS) {888
throw new ScheduleLogError(`${selector} acceptedAt must be a representable four-digit-year instant`)889
}890
return acceptedAt891
}893
/** Find the first actual daily occurrence after now and, when supplied, after a delivered date. */894
function nextDailyTarget(895
time: Temporal.PlainTime,896
timeZone: string,897
now: number,898
afterDate?: Temporal.PlainDate,899
): string | undefined {900
return nextMatchingTarget(time, timeZone, now, EVERY_DATE, afterDate)901
}903
/**904
* Resolve a daily decision near the decision's local date, not across its missed history.905
* @param record - Daily rule with a committed earliest unaccepted UTC target.906
* @param acceptedAt - Explicit wall-clock decision time, at or after the committed target.907
* @returns Latest actual due occurrence and the next future occurrence on a later local date.908
*/909
export function resolveDailyOccurrence(record: DailyScheduleRecord, acceptedAt: number): RecurringOccurrence {910
const decision = acceptedDecision('daily', acceptedAt)911
const savedTarget = Date.parse(record.scheduledAt)912
if (decision < savedTarget) throw new ScheduleLogError('daily dispatch cannot precede the active scheduledAt')913
const time = dailyTime(record.time)914
const occurrence = latestDueOccurrence(record.timeZone, EVERY_DATE, time, decision, savedTarget)915
const occurrenceAt = new Date(occurrence).toISOString()916
const nextScheduledAt = nextDailyTarget(time, record.timeZone, decision, localDate(occurrence, record.timeZone))917
return Object.freeze(nextScheduledAt === undefined ? { occurrenceAt } : { occurrenceAt, nextScheduledAt })918
}920
/**921
* Find the first actual weekly occurrence after now and, when supplied, after a delivered date.922
* @param time - Normalized local clock time of the rule.923
* @param timeZone - Explicit zone interpreting that clock time.924
* @param weekdays - Normalized explicit ISO weekdays of the rule.925
* @param now - Explicit decision time in epoch milliseconds.926
* @param afterDate - Local date of the delivered occurrence, excluded from the search.927
* @returns The first strictly future target, or exhaustion as undefined.928
*/929
function nextWeeklyTarget(930
time: Temporal.PlainTime,931
timeZone: string,932
weekdays: readonly number[],933
now: number,934
afterDate?: Temporal.PlainDate,935
): string | undefined {936
return nextMatchingTarget(time, timeZone, now, weekdaySet(weekdays), afterDate)937
}939
/**940
* Resolve a weekly decision near the decision's local date, not across its missed history.941
* @param record - Weekly rule with a committed earliest unaccepted UTC target.942
* @param acceptedAt - Explicit wall-clock decision time, at or after the committed target.943
* @returns Latest actual due occurrence and the next future occurrence on a selected weekday.944
*/945
export function resolveWeeklyOccurrence(record: WeeklyScheduleRecord, acceptedAt: number): RecurringOccurrence {946
const decision = acceptedDecision('weekly', acceptedAt)947
const savedTarget = Date.parse(record.scheduledAt)948
if (decision < savedTarget) throw new ScheduleLogError('weekly dispatch cannot precede the active scheduledAt')949
const time = weeklyTime(record.time)950
const weekdays = normalizeWeekdays(record.weekdays)951
const occurrence = latestDueOccurrence(record.timeZone, weekdaySet(weekdays), time, decision, savedTarget)952
const occurrenceAt = new Date(occurrence).toISOString()953
const nextScheduledAt = nextWeeklyTarget(time, record.timeZone, weekdays, decision, localDate(occurrence, record.timeZone))954
return Object.freeze(nextScheduledAt === undefined ? { occurrenceAt } : { occurrenceAt, nextScheduledAt })955
}957
/**958
* Build the local-date predicate of one parsed cron rule under Vixie day-of-month/day-of-week semantics.959
*960
* The star flag selects the branch only; it never excuses a field's matched values.961
* When either field text starts with `*`, a local date must satisfy BOTH fields, so a962
* stepped star such as `*` followed by `/2` still restricts the dates it matches. A bare963
* `*` matches every value, which makes that field's match always true and degrades the964
* AND to the other field, exactly like Vixie's `DOM_STAR`/`DOW_STAR` test. Only when965
* neither field text starts with `*` does either field matching suffice.966
* @param parsed - Parsed cron rule with canonical field text and matched values.967
* @returns Whether one local date matches the rule.968
*/969
function cronDateMatch(parsed: ParsedCronExpression): WeekdayMatch {970
const months = new Set(parsed.months)971
const daysOfMonth = new Set(parsed.daysOfMonth)972
const daysOfWeek = new Set(parsed.daysOfWeek)973
const dayOfMonthRestricted = !parsed.dayOfMonthStar974
const dayOfWeekRestricted = !parsed.dayOfWeekStar975
return (date) => {976
/* v8 ignore next -- both callers pre-filter by month before matching a date. */977
if (!months.has(date.month)) return false978
const dayOfMonth = daysOfMonth.has(date.day)979
const dayOfWeek = daysOfWeek.has(date.dayOfWeek % 7)980
if (dayOfMonthRestricted && dayOfWeekRestricted) return dayOfMonth || dayOfWeek981
return dayOfMonth && dayOfWeek982
}983
}985
/** Enumerate one parsed cron rule's local times of day in ascending wall-clock order. */986
function cronTimes(parsed: ParsedCronExpression): readonly Temporal.PlainTime[] {987
const times: Temporal.PlainTime[] = []988
for (const hour of parsed.hours) {989
for (const minute of parsed.minutes) times.push(Temporal.PlainTime.from({ hour, minute }))990
}991
return times992
}994
/**995
* Find the first strictly future cron occurrence without leaving the four-digit UTC year range.996
*997
* The walk stops at {@link CRON_SEARCH_HORIZON_YEARS} past the floor date when that998
* horizon precedes the four-digit ceiling: a rule with no match inside the horizon999
* never matches, so its search resolves to exhaustion instead of scanning to the ceiling.1000
* @param parsed - Parsed cron rule.1001
* @param timeZone - Explicit zone interpreting its local date and time.1002
* @param floor - Instant every returned target must exceed.1003
* @returns The first matching target, or exhaustion as undefined.1004
*/1005
function nextCronTarget(parsed: ParsedCronExpression, timeZone: string, floor: number): string | undefined {1006
const times = cronTimes(parsed)1007
const matches = cronDateMatch(parsed)1008
const months = new Set(parsed.months)1009
const firstDate = localDate(floor, timeZone)1010
let date = firstDate1011
// The floor's own local wall clock, so the first date converts only candidates1012
// that can exceed it instead of its whole day of times.1013
const floorTime = Temporal.Instant.fromEpochMilliseconds(floor).toZonedDateTimeISO(timeZone).toPlainTime()1014
const ceiling = localDate(MAX_FOUR_DIGIT_YEAR_MS, 'UTC').add({ days: 1 })1015
const horizon = date.add({ years: CRON_SEARCH_HORIZON_YEARS })1016
const lastDate = Temporal.PlainDate.compare(horizon, ceiling) < 0 ? horizon : ceiling1017
while (Temporal.PlainDate.compare(date, lastDate) <= 0) {1018
if (!months.has(date.month)) {1019
date = date.add({ months: 1 }).with({ day: 1 })1020
continue1021
}1022
if (matches(date)) {1023
for (const time of times) {1024
if (date.equals(firstDate) && Temporal.PlainTime.compare(time, floorTime) < 0) continue1025
const target = localInstant(date.toPlainDateTime(time), timeZone)1026
if (target === undefined) continue1027
if (target > MAX_FOUR_DIGIT_YEAR_MS) return undefined1028
if (target >= MIN_FOUR_DIGIT_YEAR_MS && target > floor) return new Date(target).toISOString()1029
}1030
}1031
date = date.add({ days: 1 })1032
}1033
return undefined1034
}1036
/**1037
* Find the latest matching cron occurrence at or before a decision without leaving the committed floor.1038
*1039
* The walk stops at {@link CRON_SEARCH_HORIZON_YEARS} before the decision date when that1040
* horizon follows the four-digit floor, which is the local date holding that floor instant1041
* in the rule's own zone: a rule with no match inside the horizon keeps the committed target1042
* instead of scanning back to the floor, and a candidate before the floor instant is skipped.1043
* @param parsed - Parsed cron rule.1044
* @param timeZone - Explicit zone interpreting its local date and time.1045
* @param decision - Wall-clock decision time in epoch milliseconds.1046
* @param savedTarget - Committed target that stays due even when current zone data resolves past it.1047
* @returns The latest due occurrence, or the committed target when no local occurrence is representable.1048
*/1049
function latestCronOccurrence(1050
parsed: ParsedCronExpression,1051
timeZone: string,1052
decision: number,1053
savedTarget: number,1054
): number {1055
const times = cronTimes(parsed)1056
const matches = cronDateMatch(parsed)1057
const months = new Set(parsed.months)1058
// Offsets are less than 24 hours; a date-line rollback can make a due local date1059
// later than the UTC decision date, which is why the walk starts one day after it.1060
let date = localDate(decision, 'UTC').add({ days: 1 })1061
// The floor is an instant: the earliest local date the walk may reach is the one1062
// holding it in the rule's own zone, and candidates below it are skipped, so a1063
// negative offset can still resolve a due local minute dated before the UTC floor.1064
const floorDate = localDate(MIN_FOUR_DIGIT_YEAR_MS, timeZone)1065
const horizon = date.subtract({ years: CRON_SEARCH_HORIZON_YEARS })1066
const firstDate = Temporal.PlainDate.compare(horizon, floorDate) > 0 ? horizon : floorDate1067
while (Temporal.PlainDate.compare(date, firstDate) >= 0) {1068
if (!months.has(date.month)) {1069
date = date.with({ day: 1 }).subtract({ days: 1 })1070
continue1071
}1072
if (matches(date)) {1073
// One conversion of the date's earliest wall clock decides whether the whole1074
// date lies beyond the decision: every later time on it is later too, so the1075
// walk steps back instead of converting the rest of a day that cannot match.1076
// An earliest time that a DST gap removes leaves the bound to the first time1077
// that does exist, and a date with no existing time keeps the full walk.1078
const earliest = times.reduce<number | undefined>(1079
(found, time) => found ?? localInstant(date.toPlainDateTime(time), timeZone),1080
undefined,1081
)1082
if (earliest !== undefined && earliest > decision) {1083
date = date.subtract({ days: 1 })1084
continue1085
}1086
for (const time of [...times].reverse()) {1087
const candidate = localInstant(date.toPlainDateTime(time), timeZone)1088
if (candidate === undefined || candidate > decision || candidate < MIN_FOUR_DIGIT_YEAR_MS) continue1089
// A committed target remains due even if current tzdata places the rule before it.1090
return Math.max(candidate, savedTarget)1091
}1092
}1093
date = date.subtract({ days: 1 })1094
}1095
return savedTarget1096
}1098
/**1099
* Resolve a cron decision near the decision's local date, not across its missed history.1100
* @param record - Cron rule with a committed earliest unaccepted UTC target.1101
* @param acceptedAt - Explicit wall-clock decision time, at or after the committed target.1102
* @returns Latest actual due occurrence and the first future occurrence.1103
*/1104
export function resolveCronOccurrence(record: CronScheduleRecord, acceptedAt: number): RecurringOccurrence {1105
const decision = acceptedDecision('cron', acceptedAt)1106
const savedTarget = Date.parse(record.scheduledAt)1107
if (decision < savedTarget) throw new ScheduleLogError('cron dispatch cannot precede the active scheduledAt')1108
const parsed = parseCronExpression(record.expression)1109
const occurrence = latestCronOccurrence(parsed, record.timeZone, decision, savedTarget)1110
const occurrenceAt = new Date(occurrence).toISOString()1111
const nextScheduledAt = nextCronTarget(parsed, record.timeZone, decision)1112
return Object.freeze(nextScheduledAt === undefined ? { occurrenceAt } : { occurrenceAt, nextScheduledAt })1113
}1115
/**1116
* Identify recurring Host records explicitly, excluding both one-shot variants.1117
* @param record - Current Host schedule record.1118
* @returns Whether the record uses a recurring rule.1119
*/1120
export function isRecurringScheduleRecord(record: ScheduleRecord): record is RecurringScheduleRecord {1121
return record.kind === 'every' || record.kind === 'daily' || record.kind === 'weekly' || record.kind === 'cron'1122
}1124
/**1125
* Resolve one due recurring Host record with its rule-specific calendar or interval arithmetic.1126
* @param record - Due recurring rule.1127
* @param acceptedAt - Explicit decision time in epoch milliseconds.1128
* @returns Latest due occurrence and optional future target.1129
*/1130
export function resolveRecurringOccurrence(record: RecurringScheduleRecord, acceptedAt: number): RecurringOccurrence {1131
switch (record.kind) {1132
case 'every': return resolveEveryOccurrence(record, acceptedAt)1133
case 'daily': return resolveDailyOccurrence(record, acceptedAt)1134
case 'weekly': return resolveWeeklyOccurrence(record, acceptedAt)1135
case 'cron': return resolveCronOccurrence(record, acceptedAt)1136
}1137
}1139
type DecodedDispatch = Extract<ScheduleChange, { operation: 'dispatch' }>1141
/** Apply one decoded dispatch to its exact active record. */1142
function dispatchedRecord(record: LegacyScheduleRecord, change: DecodedDispatch): LegacyScheduleRecord | undefined {1143
const hasAcceptedAt = 'acceptedAt' in change1144
if (record.kind !== 'every') {1145
if (hasAcceptedAt) throw new ScheduleLogError('one-shot dispatch must not contain acceptedAt')1146
return undefined1147
}1148
if (!hasAcceptedAt) throw new ScheduleLogError('every dispatch must contain acceptedAt')1149
const occurrence = resolveEveryOccurrence(record, Date.parse(change.acceptedAt))1150
return occurrence.nextScheduledAt === undefined1151
? undefined1152
: Object.freeze({ ...record, scheduledAt: occurrence.nextScheduledAt })1153
}1155
/**1156
* Apply already-decoded Schedule changes to one complete fold value.1157
*1158
* The transition authority for full-log replay. One mutable Map/Set pair spans1159
* the whole batch; the returned arrays are materialized and frozen once.1160
* @param folded - complete active records and used-id history before the changes.1161
* @param changes - strictly decoded durable mutations in log order.1162
* @returns the complete fold value after every mutation.1163
*/1164
export function applyScheduleChanges(1165
folded: FoldedSchedules,1166
changes: Iterable<ScheduleChange>,1167
): FoldedSchedules {1168
const active = new Map(folded.active.map(record => [record.id, record]))1169
const seen = new Set(folded.seenIds)1170
for (const change of changes) {1171
switch (change.operation) {1172
case 'create':1173
if (seen.has(change.schedule.id)) {1174
throw new ScheduleLogError(`schedule id ${JSON.stringify(change.schedule.id)} was reused`)1175
}1176
seen.add(change.schedule.id)1177
active.set(change.schedule.id, change.schedule)1178
break1179
case 'delete':1180
if (!active.delete(change.id)) {1181
throw new ScheduleLogError(`schedule delete targets inactive id ${JSON.stringify(change.id)}`)1182
}1183
break1184
case 'dispatch': {1185
const record = active.get(change.id)1186
if (record === undefined) {1187
throw new ScheduleLogError(`schedule dispatch targets inactive id ${JSON.stringify(change.id)}`)1188
}1189
const next = dispatchedRecord(record, change)1190
if (next === undefined) active.delete(change.id)1191
else active.set(change.id, next)1192
break1193
}1194
/* v8 ignore next 3 -- decodeScheduleChange returns a closed operation union. */1195
default: {1196
const unreachable: never = change1197
throw new ScheduleLogError(`unknown decoded schedule change ${String(unreachable)}`)1198
}1199
}1200
}1201
return Object.freeze({1202
active: Object.freeze([...active.values()]),1203
seenIds: Object.freeze([...seen]),1204
})1205
}1207
/**1208
* Fold the package-owned stream after the durable fork seed boundary.1209
* @param events - Complete ordered session log or candidate-extended log.1210
* @param inheritedEventCount - Inherited prefix length excluded from child ownership.1211
* @returns Active records and all previously used ids.1212
*/1213
export function foldScheduleEvents(1214
events: readonly SessionEvent[],1215
inheritedEventCount: SessionLogOffsetType = SessionLogOffset(0),1216
): FoldedSchedules {1217
if (!Number.isSafeInteger(inheritedEventCount)1218
|| inheritedEventCount < 01219
|| inheritedEventCount > events.length) {1220
throw new ScheduleLogError('schedule inheritedEventCount must be within the supplied event log')1221
}1222
const initial: FoldedSchedules = Object.freeze({1223
active: Object.freeze([]),1224
seenIds: Object.freeze([]),1225
})1226
const changes = function* (): Generator<ScheduleChange> {1227
for (const event of events.slice(inheritedEventCount)) {1228
if (event.type === 'schedule/change') yield decodeScheduleChange(event.data)1229
}1230
}1231
return applyScheduleChanges(initial, changes())1232
}1234
/**1235
* Allocate the next readable id without reusing any prior session-local id.1236
* @param folded - Fold containing every previously created id.1237
* @returns A fresh `schedule-N` identity.1238
*/1239
export function allocateScheduleId(folded: FoldedSchedules): ScheduleIdType {1240
const seen = new Set(folded.seenIds)1241
let sequence = seen.size + 11242
let candidate = ScheduleId(`schedule-${sequence}`)1243
while (seen.has(candidate)) {1244
sequence += 11245
candidate = ScheduleId(`schedule-${sequence}`)1246
}1247
return candidate1248
}1250
/**1251
* Validate a model after rule and compute its durable target.1252
* @param id - Already allocated task id.1253
* @param prompt - Reminder content supplied at creation.1254
* @param afterSeconds - Requested positive delay.1255
* @param now - Single rule-acceptance wall-clock sample in epoch milliseconds.1256
* @param title - Required task name supplied at creation.1257
* @returns Frozen durable after record.1258
*/1259
export function createAfterScheduleRecord(1260
id: ScheduleIdType,1261
prompt: string,1262
afterSeconds: number,1263
now: number,1264
title: string,1265
): AfterScheduleRecord {1266
const normalizedPrompt = prompt.trim()1267
if (normalizedPrompt.length === 0) {1268
throw new ScheduleInputError('invalid_prompt', 'prompt must be non-empty after trimming.')1269
}1270
if (!Number.isSafeInteger(afterSeconds) || afterSeconds <= 0) {1271
throw new ScheduleInputError('invalid_rule', 'after_seconds must be a positive safe integer.')1272
}1273
const delay = afterSeconds * 1_0001274
const target = now + delay1275
return Object.freeze({1276
id,1277
kind: 'after',1278
title: scheduleTitle(title),1279
prompt: normalizedPrompt,1280
afterSeconds,1281
scheduledAt: futureInstant(target, now),1282
})1283
}1285
/**1286
* Validate an absolute selector and compute its sole durable UTC target.1287
* @param id - Already allocated task id.1288
* @param prompt - Reminder content supplied at creation.1289
* @param at - Explicit-offset instant or structured local calendar value.1290
* @param now - Single rule-acceptance wall-clock sample in epoch milliseconds.1291
* @param title - Required task name supplied at creation.1292
* @returns Frozen durable absolute one-shot record.1293
*/1294
export function createAtScheduleRecord(1295
id: ScheduleIdType,1296
prompt: string,1297
at: AtInput,1298
now: number,1299
title: string,1300
): AtScheduleRecord {1301
const normalizedPrompt = prompt.trim()1302
if (normalizedPrompt.length === 0) {1303
throw new ScheduleInputError('invalid_prompt', 'prompt must be non-empty after trimming.')1304
}1306
return Object.freeze({1307
id,1308
kind: 'at',1309
title: scheduleTitle(title),1310
prompt: normalizedPrompt,1311
scheduledAt: futureInstant(parseAtInput(at), now),1312
})1313
}1315
/**1316
* Parse an absolute selector without requiring it to be future.1317
* @param at - Explicit-offset instant or strict local calendar input.1318
* @returns Resolved epoch milliseconds; malformed input throws ScheduleInputError.1319
*/1320
export function parseAtInput(at: AtInput): number {1321
let target: number1322
if (typeof at === 'string') {1323
target = parseOffsetInstant(at)1324
} else if (isRecord(at)) {1325
if (!hasExactKeys(at, ['date', 'time', 'time_zone'])) {1326
throw new ScheduleInputError('invalid_rule', 'Local at must contain exactly date, time, and time_zone.')1327
}1328
if (typeof at['date'] !== 'string' || typeof at['time'] !== 'string') {1329
throw new ScheduleInputError('invalid_rule', 'Local at date and time must be strings.')1330
}1331
const rawTimeZone = at['time_zone']1332
if (typeof rawTimeZone !== 'string') {1333
throw new ScheduleInputError('invalid_time_zone', 'time_zone must be a string.')1334
}1335
const local: LocalAtInput = {1336
date: at['date'],1337
time: at['time'],1338
time_zone: rawTimeZone,1339
}1340
target = resolveLocalInstant(parseLocalAt(local), canonicalizeTimeZone(rawTimeZone))1341
} else {1342
throw new ScheduleInputError('invalid_rule', 'at must be an explicit-offset string or local calendar object.')1343
}1345
return target1346
}1348
/**1349
* Validate a fixed-rate selector and compute the first target of a new interval anchor.1350
* @param id - Already allocated task id.1351
* @param prompt - Reminder content supplied at creation.1352
* @param everySeconds - Requested fixed safe-integer interval.1353
* @param now - Single rule-acceptance wall-clock sample in epoch milliseconds.1354
* @param title - Required task name supplied at creation.1355
* @returns Frozen durable fixed-rate record.1356
*/1357
export function createEveryScheduleRecord(1358
id: ScheduleIdType,1359
prompt: string,1360
everySeconds: number,1361
now: number,1362
title: string,1363
): EveryScheduleRecord {1364
const normalizedPrompt = prompt.trim()1365
if (normalizedPrompt.length === 0) {1366
throw new ScheduleInputError('invalid_prompt', 'prompt must be non-empty after trimming.')1367
}1368
if (!Number.isSafeInteger(everySeconds)) {1369
throw new ScheduleInputError('invalid_rule', 'every_seconds must be a safe integer.')1370
}1371
if (everySeconds < MIN_EVERY_INTERVAL_SECONDS) {1372
throw new ScheduleInputError(1373
'frequency_too_high',1374
`every_seconds must be at least ${MIN_EVERY_INTERVAL_SECONDS}.`,1375
)1376
}1377
const interval = everySeconds * 1_0001378
const target = now + interval1379
return Object.freeze({1380
id,1381
kind: 'every',1382
title: scheduleTitle(title),1383
prompt: normalizedPrompt,1384
everySeconds,1385
scheduledAt: futureInstant(target, now),1386
})1387
}1389
/**1390
* Create a daily wall-clock rule with a strictly future committed UTC target.1391
* @param id - Already allocated task identity.1392
* @param prompt - Reminder content supplied at creation.1393
* @param daily - Strict local time and explicit IANA zone.1394
* @param now - Single rule-acceptance wall-clock sample in epoch milliseconds.1395
* @param title - Required task name supplied at creation.1396
* @returns Frozen daily record; absent future dates throw time_out_of_range.1397
*/1398
export function createDailyScheduleRecord(1399
id: ScheduleIdType,1400
prompt: string,1401
daily: DailyInput,1402
now: number,1403
title: string,1404
): DailyScheduleRecord {1405
const normalizedPrompt = prompt.trim()1406
if (normalizedPrompt.length === 0) {1407
throw new ScheduleInputError('invalid_prompt', 'prompt must be non-empty after trimming.')1408
}1409
const { time, timeZone } = parseDailyInput(daily)1410
if (!Number.isSafeInteger(now) || now < MIN_FOUR_DIGIT_YEAR_MS || now > MAX_FOUR_DIGIT_YEAR_MS) {1411
throw new ScheduleInputError('time_out_of_range', 'Daily creation time must be a representable four-digit-year UTC instant.')1412
}1413
const scheduledAt = nextDailyTarget(dailyTime(time), timeZone, now)1414
if (scheduledAt === undefined) {1415
throw new ScheduleInputError('time_out_of_range', 'No future daily occurrence is representable as a four-digit-year UTC instant.')1416
}1417
return Object.freeze({1418
id, kind: 'daily', title: scheduleTitle(title), prompt: normalizedPrompt, time, timeZone, scheduledAt,1419
})1420
}1422
/**1423
* Normalize a daily selector without calculating a new committed target.1424
* @param daily - Strict local time and explicit IANA zone.1425
* @returns Normalized time and canonical zone; malformed input throws ScheduleInputError.1426
*/1427
export function parseDailyInput(daily: DailyInput): { readonly time: string; readonly timeZone: string } {1428
if (!isRecord(daily) || !hasExactKeys(daily, ['time', 'time_zone']) || typeof daily['time'] !== 'string') {1429
throw new ScheduleInputError(1430
'invalid_rule', 'daily must contain exactly time and time_zone, with time HH:mm:ss and optional 1-3 fractional digits.',1431
)1432
}1433
if (typeof daily['time_zone'] !== 'string') {1434
throw new ScheduleInputError('invalid_time_zone', 'time_zone must be a string.')1435
}1436
return {1437
time: dailyTime(daily['time']).toString({ fractionalSecondDigits: 3 }),1438
timeZone: canonicalizeTimeZone(daily['time_zone']),1439
}1440
}1442
/**1443
* Create a weekly wall-clock rule with a strictly future committed UTC target.1444
* @param id - Already allocated task identity.1445
* @param prompt - Reminder content supplied at creation.1446
* @param weekly - Strict local time, explicit IANA zone, and explicit ISO weekday set.1447
* @param now - Single rule-acceptance wall-clock sample in epoch milliseconds.1448
* @param title - Required task name supplied at creation.1449
* @returns Frozen weekly record; absent future weekdays throw time_out_of_range.1450
*/1451
export function createWeeklyScheduleRecord(1452
id: ScheduleIdType,1453
prompt: string,1454
weekly: WeeklyInput,1455
now: number,1456
title: string,1457
): WeeklyScheduleRecord {1458
const normalizedPrompt = prompt.trim()1459
if (normalizedPrompt.length === 0) {1460
throw new ScheduleInputError('invalid_prompt', 'prompt must be non-empty after trimming.')1461
}1462
const { time, timeZone, weekdays } = parseWeeklyInput(weekly)1463
if (!Number.isSafeInteger(now) || now < MIN_FOUR_DIGIT_YEAR_MS || now > MAX_FOUR_DIGIT_YEAR_MS) {1464
throw new ScheduleInputError('time_out_of_range', 'Weekly creation time must be a representable four-digit-year UTC instant.')1465
}1466
const scheduledAt = nextWeeklyTarget(weeklyTime(time), timeZone, weekdays, now)1467
if (scheduledAt === undefined) {1468
throw new ScheduleInputError('time_out_of_range', 'No future weekly occurrence is representable as a four-digit-year UTC instant.')1469
}1470
return Object.freeze({1471
id, kind: 'weekly', title: scheduleTitle(title), prompt: normalizedPrompt, time, timeZone,1472
weekdays, scheduledAt,1473
})1474
}1476
/**1477
* Create a cron wall-clock rule with a strictly future committed UTC target.1478
* @param id - Already allocated task identity.1479
* @param prompt - Reminder content supplied at creation.1480
* @param cron - Strict five-field expression and explicit IANA zone.1481
* @param now - Single rule-acceptance wall-clock sample in epoch milliseconds.1482
* @param title - Required task name supplied at creation.1483
* @returns Frozen cron record; absent future occurrences throw time_out_of_range.1484
*/1485
export function createCronScheduleRecord(1486
id: ScheduleIdType,1487
prompt: string,1488
cron: CronInput,1489
now: number,1490
title: string,1491
): CronScheduleRecord {1492
const normalizedPrompt = prompt.trim()1493
if (normalizedPrompt.length === 0) {1494
throw new ScheduleInputError('invalid_prompt', 'prompt must be non-empty after trimming.')1495
}1496
const { expression, timeZone } = parseCronInput(cron)1497
if (!Number.isSafeInteger(now) || now < MIN_FOUR_DIGIT_YEAR_MS || now > MAX_FOUR_DIGIT_YEAR_MS) {1498
throw new ScheduleInputError('time_out_of_range', 'Cron creation time must be a representable four-digit-year UTC instant.')1499
}1500
const scheduledAt = nextCronTarget(parseCronExpression(expression), timeZone, now)1501
if (scheduledAt === undefined) {1502
throw new ScheduleInputError('time_out_of_range', 'No future cron occurrence is representable as a four-digit-year UTC instant.')1503
}1504
return Object.freeze({1505
id, kind: 'cron', title: scheduleTitle(title), prompt: normalizedPrompt, expression, timeZone, scheduledAt,1506
})1507
}1509
/**1510
* Normalize a weekly selector without calculating a new committed target.1511
* @param weekly - Strict local time, explicit IANA zone, and explicit ISO weekday set.1512
* @returns Normalized time, canonical zone, and unique ascending weekdays; malformed input throws ScheduleInputError.1513
*/1514
export function parseWeeklyInput(1515
weekly: WeeklyInput,1516
): { readonly time: string; readonly timeZone: string; readonly weekdays: number[] } {1517
if (!isRecord(weekly)1518
|| !hasExactKeys(weekly, ['time', 'time_zone', 'weekdays'])1519
|| typeof weekly['time'] !== 'string') {1520
throw new ScheduleInputError(1521
'invalid_rule',1522
'weekly must contain exactly time, time_zone, and weekdays, with time HH:mm:ss and optional 1-3 fractional digits.',1523
)1524
}1525
if (typeof weekly['time_zone'] !== 'string') {1526
throw new ScheduleInputError('invalid_time_zone', 'time_zone must be a string.')1527
}1528
return {1529
time: weeklyTime(weekly['time']).toString({ fractionalSecondDigits: 3 }),1530
timeZone: canonicalizeTimeZone(weekly['time_zone']),1531
weekdays: normalizeWeekdays(weekly['weekdays']),1532
}1533
}1535
/** Field bounds of the supported five-field cron dialect, in evaluation order. */1536
interface CronFieldSpec {1537
/** Field name used in diagnostics. */1538
readonly name: string1539
/** Lowest value accepted in the input dialect. */1540
readonly min: number1541
/** Highest value accepted in the input dialect; Sunday accepts both 0 and 7. */1542
readonly max: number1543
/** Highest value retained after folding Sunday 7 onto 0. */1544
readonly canonicalMax: number1545
}1547
const CRON_FIELDS: readonly [CronFieldSpec, CronFieldSpec, CronFieldSpec, CronFieldSpec, CronFieldSpec] = [1548
{ name: 'minute', min: 0, max: 59, canonicalMax: 59 },1549
{ name: 'hour', min: 0, max: 23, canonicalMax: 23 },1550
{ name: 'day-of-month', min: 1, max: 31, canonicalMax: 31 },1551
{ name: 'month', min: 1, max: 12, canonicalMax: 12 },1552
{ name: 'day-of-week', min: 0, max: 7, canonicalMax: 6 },1553
]1555
/** One parsed cron field: its matched values, canonical spelling, and Vixie star flag. */1556
interface ParsedCronField {1557
/** Unique ascending matched values after Sunday folding. */1558
readonly values: readonly number[]1559
/** Canonical spelling of exactly those values. */1560
readonly canonical: string1561
/** Whether the field text starts with `*`, which is Vixie's DOM_STAR/DOW_STAR test. */1562
readonly star: boolean1563
}1565
/** Build the stable diagnostic for one malformed cron field element. */1566
function invalidCronField(spec: CronFieldSpec, element: string): ScheduleInputError {1567
return new ScheduleInputError(1568
'invalid_rule',1569
`cron.expression ${spec.name} field element ${JSON.stringify(element)} must be *, a value, a-b, */n, a-b/n, `1570
+ 'or a comma-separated list of those.',1571
)1572
}1574
/** Read one in-range cron field value. */1575
function cronFieldValue(text: string, spec: CronFieldSpec): number {1576
const value = Number(text)1577
if (!Number.isSafeInteger(value) || value < spec.min || value > spec.max) {1578
throw new ScheduleInputError(1579
'invalid_rule',1580
`cron.expression ${spec.name} field value ${text} is outside ${spec.min}-${spec.max}.`,1581
)1582
}1583
return value1584
}1586
/** Read one positive cron field step. */1587
function cronFieldStep(text: string, spec: CronFieldSpec): number {1588
const step = Number(text)1589
if (!Number.isSafeInteger(step) || step < 1) {1590
throw new ScheduleInputError('invalid_rule', `cron.expression ${spec.name} field step must be a positive integer.`)1591
}1592
return step1593
}1595
/** Expand one comma-separated cron field into its matched value set. */1596
function cronFieldValues(raw: string, spec: CronFieldSpec): number[] {1597
const matched = new Set<number>()1598
for (const element of raw.split(',')) {1599
if (element.length === 0) {1600
throw new ScheduleInputError(1601
'invalid_rule', `cron.expression ${spec.name} field must not contain an empty list element.`,1602
)1603
}1604
if (element === '*') {1605
for (let value = spec.min; value <= spec.max; value += 1) matched.add(value)1606
continue1607
}1608
if (element.startsWith('*')) {1609
const stepped = /^\*\/(?<step>\d+)$/.exec(element)1610
const groups = stepped?.groups1611
if (groups === undefined) throw invalidCronField(spec, element)1612
const stepText = groups['step']1613
/* v8 ignore next -- a successful fixed regex always provides the step group. */1614
if (stepText === undefined) throw invalidCronField(spec, element)1615
const step = cronFieldStep(stepText, spec)1616
for (let value = spec.min; value <= spec.max; value += step) matched.add(value)1617
continue1618
}1619
const parsed = /^(?<start>\d+)(?:-(?<end>\d+))?(?:\/(?<step>\d+))?$/.exec(element)1620
const groups = parsed?.groups1621
if (groups === undefined) throw invalidCronField(spec, element)1622
const startText = groups['start']1623
/* v8 ignore next -- a successful fixed regex always provides the start group. */1624
if (startText === undefined) throw invalidCronField(spec, element)1625
const start = cronFieldValue(startText, spec)1626
const end = groups['end']1627
const stepText = groups['step']1628
if (end === undefined) {1629
if (stepText !== undefined) throw invalidCronField(spec, element)1630
matched.add(start)1631
continue1632
}1633
const last = cronFieldValue(end, spec)1634
if (start > last) {1635
throw new ScheduleInputError(1636
'invalid_rule', `cron.expression ${spec.name} field range ${start}-${last} is inverted.`,1637
)1638
}1639
const step = stepText === undefined ? 1 : cronFieldStep(stepText, spec)1640
for (let value = start; value <= last; value += step) matched.add(value)1641
}1642
const values = new Set<number>()1643
for (const value of matched) {1644
values.add(spec.canonicalMax !== spec.max && value === spec.max ? spec.min : value)1645
}1646
return [...values].sort((left, right) => left - right)1647
}1649
/**1650
* Read one value the encoder already proved present.1651
* @param values - matched value set the parser always fills.1652
* @param index - index the caller derived from that set.1653
* @returns the value at that index.1654
*/1655
function cronFieldValueAt(values: readonly number[], index: number): number {1656
const value = values[index]1657
/* v8 ignore next -- a parsed cron field always matches at least one value. */1658
if (value === undefined) throw new ScheduleLogError('cron field encoding requires a non-empty value set')1659
return value1660
}1662
/** Encode one matched value set as the shortest equivalent comma-separated cron field.1663
* @param values - Matched values in ascending order.1664
* @returns Field text that re-parses to exactly `values`, never spelled with a leading `*`.1665
*/1666
function encodeCronField(values: readonly number[]): string {1667
const first = cronFieldValueAt(values, 0)1668
if (values.length === 1) return String(first)1669
const last = cronFieldValueAt(values, values.length - 1)1670
const step = cronFieldValueAt(values, 1) - first1671
let uniform = true1672
for (let index = 2; index < values.length; index += 1) {1673
if (cronFieldValueAt(values, index) - cronFieldValueAt(values, index - 1) !== step) {1674
uniform = false1675
break1676
}1677
}1678
if (uniform) {1679
if (step === 1) return `${first}-${last}`1680
return `${first}-${last}/${step}`1681
}1682
const parts: string[] = []1683
let runStart = first1684
for (let index = 1; index < values.length; index += 1) {1685
const current = cronFieldValueAt(values, index)1686
const previous = cronFieldValueAt(values, index - 1)1687
if (current === previous + 1) continue1688
parts.push(runStart === previous ? String(runStart) : `${runStart}-${previous}`)1689
runStart = current1690
}1691
const final = cronFieldValueAt(values, values.length - 1)1692
parts.push(runStart === final ? String(runStart) : `${runStart}-${final}`)1693
return parts.join(',')1694
}1696
/**1697
* One uniform walk from a field's minimum, folded exactly as a parsed field folds.1698
* @param spec - Field range and canonical maximum.1699
* @param step - Positive step of the walk.1700
* @returns Ascending values `step` produces from the minimum.1701
*/1702
function starWalk(spec: CronFieldSpec, step: number): number[] {1703
const walked = new Set<number>()1704
for (let value = spec.min; value <= spec.max; value += step) {1705
walked.add(spec.canonicalMax !== spec.max && value === spec.max ? spec.min : value)1706
}1707
return [...walked].sort((left, right) => left - right)1708
}1710
/**1711
* Canonical spelling of a field whose text started with `*`.1712
*1713
* Only a `*`-prefixed spelling keeps the star flag, and only the bare star or a star-step1714
* (a `*` followed by `/n`) admits a leading `*`, so the encoding is the bare star for every1715
* value, otherwise the widest star-step walk plus any remaining values as a list.1716
* Re-parsing therefore reproduces both the star flag and the matched set, which is what1717
* keeps day-of-month/day-of-week AND/OR selection stable across a stored canonical1718
* expression.1719
* @param values - Matched values in ascending order.1720
* @param spec - Field range and canonical maximum.1721
* @returns Canonical field text that starts with `*`.1722
*/1723
function encodeStarCronField(values: readonly number[], spec: CronFieldSpec): string {1724
if (values.length === spec.canonicalMax - spec.min + 1) return '*'1725
const present = new Set(values)1726
let bestStep: number | undefined1727
let bestWalk: number[] = []1728
for (let step = 1; step <= spec.max - spec.min + 1; step += 1) {1729
const walk = starWalk(spec, step)1730
if (walk.length > bestWalk.length && walk.every(value => present.has(value))) {1731
bestStep = step1732
bestWalk = walk1733
}1734
}1735
/* v8 ignore next -- the widest step yields the single minimum, which every `*`-led field matches. */1736
if (bestStep === undefined) throw new ScheduleInputError('invalid_rule', `${spec.name} cannot keep a leading \`*\`.`)1737
const walked = new Set(bestWalk)1738
const remaining = values.filter(value => !walked.has(value))1739
const walkText = `*/${bestStep}`1740
return remaining.length === 01741
? walkText1742
: `${walkText},${encodeCronField(remaining)}`1743
}1745
/**1746
* Parse one cron field into its matched values, canonical spelling, and star flag.1747
*1748
* The star flag tests the field text's first character, which is how Vixie sets1749
* `DOM_STAR`/`DOW_STAR`: a stepped star (`*` followed by `/2`) is a star although1750
* it matches half the range, while an explicit full range such as `1-31` is1751
* restricted. Canonicalization preserves the flag: a `*`-led field keeps a `*`-led1752
* spelling, and a field that did not start with `*` is never spelled as a star-step.1753
*/1754
function parseCronField(raw: string, spec: CronFieldSpec): ParsedCronField {1755
const values = cronFieldValues(raw, spec)1756
const star = raw.startsWith('*')1757
return Object.freeze({1758
values: Object.freeze(values),1759
canonical: star ? encodeStarCronField(values, spec) : encodeCronField(values),1760
star,1761
})1762
}1764
/** Parsed five-field cron rule: canonical text plus every field's matched values. */1765
interface ParsedCronExpression {1766
/** Canonical expression stored in the durable record. */1767
readonly expression: string1768
/** Unique ascending matched minutes. */1769
readonly minutes: readonly number[]1770
/** Unique ascending matched hours. */1771
readonly hours: readonly number[]1772
/** Unique ascending matched days of the month. */1773
readonly daysOfMonth: readonly number[]1774
/** Unique ascending matched months. */1775
readonly months: readonly number[]1776
/** Unique ascending matched cron weekdays, Sunday 0 through Saturday 6. */1777
readonly daysOfWeek: readonly number[]1778
/** Whether the day-of-month field text starts with `*` (Vixie `DOM_STAR`). */1779
readonly dayOfMonthStar: boolean1780
/** Whether the day-of-week field text starts with `*` (Vixie `DOW_STAR`). */1781
readonly dayOfWeekStar: boolean1782
}1784
/** Parse and canonicalize one strict five-field cron expression. */1785
function parseCronExpression(expression: string): ParsedCronExpression {1786
if (typeof expression !== 'string' || expression.length === 0 || expression.trim() !== expression) {1787
throw new ScheduleInputError('invalid_rule', 'cron.expression must be a non-empty trimmed string.')1788
}1789
const fields = expression.split(/\s+/)1790
if (fields.length !== 5) {1791
throw new ScheduleInputError(1792
'invalid_rule',1793
'cron.expression must contain exactly five whitespace-separated fields: '1794
+ 'minute hour day-of-month month day-of-week.',1795
)1796
}1797
const [minute, hour, dayOfMonth, month, dayOfWeek] = fields.map(1798
(field, index) => {1799
const fieldSpec = CRON_FIELDS[index]1800
/* v8 ignore next -- the five-field split bounds this index. */1801
if (fieldSpec === undefined) throw new ScheduleLogError('cron field specification is missing')1802
return parseCronField(field, fieldSpec)1803
},1804
) as [ParsedCronField, ParsedCronField, ParsedCronField, ParsedCronField, ParsedCronField]1805
return Object.freeze({1806
expression: [minute, hour, dayOfMonth, month, dayOfWeek].map(field => field.canonical).join(' '),1807
minutes: minute.values,1808
hours: hour.values,1809
daysOfMonth: dayOfMonth.values,1810
months: month.values,1811
daysOfWeek: dayOfWeek.values,1812
dayOfMonthStar: dayOfMonth.star,1813
dayOfWeekStar: dayOfWeek.star,1814
})1815
}1817
/**1818
* Canonicalize one strict five-field cron expression.1819
* @param expression - Candidate `minute hour day-of-month month day-of-week` expression.1820
* @returns The canonical expression; malformed or unsupported input throws ScheduleInputError.1821
*/1822
export function canonicalizeCronExpression(expression: string): string {1823
return parseCronExpression(expression).expression1824
}1826
/**1827
* Normalize a cron selector without calculating a new committed target.1828
* @param cron - Strict five-field expression and explicit IANA zone.1829
* @returns The canonical expression and canonical zone; malformed input throws ScheduleInputError.1830
*/1831
export function parseCronInput(cron: CronInput): { readonly expression: string; readonly timeZone: string } {1832
if (!isRecord(cron) || !hasExactKeys(cron, ['expression', 'time_zone']) || typeof cron['expression'] !== 'string') {1833
throw new ScheduleInputError(1834
'invalid_rule', 'cron must contain exactly expression and time_zone, with a five-field cron expression.',1835
)1836
}1837
if (typeof cron['time_zone'] !== 'string') {1838
throw new ScheduleInputError('invalid_time_zone', 'time_zone must be a string.')1839
}1840
return {1841
expression: parseCronExpression(cron['expression']).expression,1842
timeZone: canonicalizeTimeZone(cron['time_zone']),1843
}1844
}1846
/**1847
* Derive one execution-local management view.1848
* @param record - Active durable record.1849
* @param now - Wall-clock sample used for its timing state.1850
* @returns Complete Host delivery view.1851
*/1852
export function scheduleView(record: ScheduleRecord, now: number): ScheduleView {1853
return Object.freeze({1854
...record,1855
state: now >= Date.parse(record.scheduledAt) ? 'overdue' : 'scheduled',1856
deliveryMode: 'host',1857
})1858
}1860
/** Fixed model-facing origin line shared by one-shot and recurring reminder delivery. */1861
const SCHEDULED_MESSAGE_FRAMING = 'This is a scheduled message from the user'1863
/**1864
* Render the fixed model framing for a due reminder.1865
* @param record - Due active record.1866
* @returns Stable model-visible text with JSON-escaped dynamic fields.1867
*/1868
export function renderReminderFraming(record: OneShotScheduleRecord): string {1869
return [1870
'[SCHEDULE REMINDER]',1871
SCHEDULED_MESSAGE_FRAMING,1872
`schedule_id_json: ${JSON.stringify(record.id)}`,1873
`occurrence_at: ${record.scheduledAt}`,1874
`reminder_prompt_json: ${JSON.stringify(record.prompt)}`,1875
].join('\n')1876
}1878
/**1879
* Render one recurring reminder batch in the supplied order.1880
* @param reminders - Complete admitted batch with one latest occurrence per record.1881
* @returns Stable model-visible text whose dynamic payload is canonical JSON.1882
*/1883
export function renderRecurringReminderBatchFraming(1884
reminders: readonly { readonly record: RecurringScheduleRecord; readonly occurrenceAt: string }[],1885
): string {1886
const payload = reminders.map(({ record, occurrenceAt }) => ({1887
schedule_id: record.id,1888
occurrence_at: occurrenceAt,1889
reminder_prompt: record.prompt,1890
}))1891
return [1892
'[SCHEDULE REMINDER BATCH]',1893
SCHEDULED_MESSAGE_FRAMING,1894
`reminders_json: ${JSON.stringify(payload)}`,1895
].join('\n')1896
}