Parcourir la source

fix(schedule): validate resumed cron rules live

pku-xht il y a 1 mois
Parent
commit
925c0558a0

+ 28 - 6
packages/schedule/tool-schedule/src/domain.ts

@@ -872,23 +872,44 @@ function previousCronInstant(
   return latestCronInstantThrough(rule, timeZone, baseline, acceptedAt)
   return latestCronInstantThrough(rule, timeZone, baseline, acceptedAt)
 }
 }
 
 
-/** Validate one newly appended Cron record against the current parser, ICU, and calendar adapter. */
-function validateLiveCronRecord(record: CronScheduleRecord): void {
+/** Normalize a current calendar-validation failure for the package invariant. */
+function throwLiveCronValidationError(error: unknown): never {
+  if (error instanceof ScheduleLogError) throw error
+  /* v8 ignore next -- current parser and adapter failures are Error subclasses. */
+  const detail = error instanceof Error ? error.message : String(error)
+  throw new ScheduleLogError(`live cron record is invalid: ${detail}`)
+}
+
+/** Validate one Cron rule and zone against current grammar, frequency, and ICU data. */
+function validateLiveCronRule(record: CronScheduleRecord): {
+  readonly rule: ParsedCronRule
+  readonly timeZone: string
+} {
   try {
   try {
     const rule = parseCronRule(record.cron)
     const rule = parseCronRule(record.cron)
     const timeZone = canonicalizeTimeZone(record.timeZone)
     const timeZone = canonicalizeTimeZone(record.timeZone)
     if (timeZone !== record.timeZone) {
     if (timeZone !== record.timeZone) {
       throw new ScheduleLogError('live cron timeZone must use its current canonical IANA name')
       throw new ScheduleLogError('live cron timeZone must use its current canonical IANA name')
     }
     }
+    if (!rule.hasMatchingDate) {
+      throw new ScheduleLogError('live cron rule must have a matching Gregorian date')
+    }
+    return { rule, timeZone }
+  } catch (error: unknown) {
+    throwLiveCronValidationError(error)
+  }
+}
+
+/** Validate one newly appended Cron record against the current calendar adapter. */
+function validateLiveCronRecord(record: CronScheduleRecord): void {
+  const { rule, timeZone } = validateLiveCronRule(record)
+  try {
     const target = Date.parse(record.scheduledAt)
     const target = Date.parse(record.scheduledAt)
     if (nextCronInstant(rule, timeZone, target - 60_000) !== target) {
     if (nextCronInstant(rule, timeZone, target - 60_000) !== target) {
       throw new ScheduleLogError('live cron scheduledAt must match its rule in the current time-zone data')
       throw new ScheduleLogError('live cron scheduledAt must match its rule in the current time-zone data')
     }
     }
   } catch (error: unknown) {
   } catch (error: unknown) {
-    if (error instanceof ScheduleLogError) throw error
-    /* v8 ignore next -- current parser and adapter failures are Error subclasses. */
-    const detail = error instanceof Error ? error.message : String(error)
-    throw new ScheduleLogError(`live cron record is invalid: ${detail}`)
+    throwLiveCronValidationError(error)
   }
   }
 }
 }
 
 
@@ -1310,6 +1331,7 @@ export function validateLiveScheduleChange(
   const record = foldScheduleEvents(events, seedLength).active.find(candidate => candidate.id === change.id)
   const record = foldScheduleEvents(events, seedLength).active.find(candidate => candidate.id === change.id)
   /* v8 ignore next -- the preceding candidate fold requires calendar fields to target an active Cron record. */
   /* v8 ignore next -- the preceding candidate fold requires calendar fields to target an active Cron record. */
   if (record?.kind !== 'cron') return
   if (record?.kind !== 'cron') return
+  validateLiveCronRule(record)
   const expected = resolveCronOccurrence(record, Date.parse(change.acceptedAt))
   const expected = resolveCronOccurrence(record, Date.parse(change.acceptedAt))
   const nextScheduledAt = 'nextScheduledAt' in change ? change.nextScheduledAt : undefined
   const nextScheduledAt = 'nextScheduledAt' in change ? change.nextScheduledAt : undefined
   if (change.occurrenceAt !== expected.occurrenceAt || nextScheduledAt !== expected.nextScheduledAt) {
   if (change.occurrenceAt !== expected.occurrenceAt || nextScheduledAt !== expected.nextScheduledAt) {

+ 42 - 0
packages/schedule/tool-schedule/tests/invariant.spec.ts

@@ -143,6 +143,48 @@ describe('Schedule package invariant', () => {
       }, 0)],
       }, 0)],
     })
     })
     const fiber = await ctx.plugin(scheduleInvariant)
     const fiber = await ctx.plugin(scheduleInvariant)
+    const invalidLiveRules = [
+      {
+        id: 'schedule-historical-fast-cron',
+        cron: '* * * * *',
+        scheduledAt: '2026-08-06T12:00:00.000Z',
+        occurrenceAt: '2026-08-06T12:01:00.000Z',
+        acceptedAt: '2026-08-06T12:01:00.000Z',
+        nextScheduledAt: '2026-08-06T12:02:00.000Z',
+      },
+      {
+        id: 'schedule-historical-impossible-cron',
+        cron: '0 0 31 2 *',
+        scheduledAt: '2026-02-01T00:00:00.000Z',
+        occurrenceAt: '2026-02-01T00:00:00.000Z',
+        acceptedAt: '2026-02-01T00:00:00.000Z',
+        nextScheduledAt: undefined,
+      },
+    ] as const
+    for (const invalid of invalidLiveRules) {
+      const replay = ctx.sessions.create(SessionId(invalid.id), {
+        seed: [event({
+          version: 1,
+          operation: 'create',
+          schedule: {
+            id: invalid.id,
+            kind: 'cron',
+            prompt: 'historical rule',
+            cron: invalid.cron,
+            timeZone: 'UTC',
+            scheduledAt: invalid.scheduledAt,
+          },
+        }, 0)],
+      })
+      expect(() => replay.append('schedule/change', {
+        version: 1,
+        operation: 'dispatch',
+        id: ScheduleId(invalid.id),
+        occurrenceAt: invalid.occurrenceAt,
+        acceptedAt: invalid.acceptedAt,
+        ...(invalid.nextScheduledAt === undefined ? {} : { nextScheduledAt: invalid.nextScheduledAt }),
+      })).toThrow(InvariantError)
+    }
     await fiber.dispose()
     await fiber.dispose()
     await ctx.fiber.dispose()
     await ctx.fiber.dispose()
   })
   })