koala73/worldmonitor · error · ConvexError

quietHoursStart and quietHoursEnd must differ (same value =…

Error message

quietHoursStart and quietHoursEnd must differ (same value = no quiet window)

What it means

setQuietHours rejects an effective start === end because identical bounds denote an empty window (convex/alertRules.ts:457-462). The check uses EFFECTIVE values: args.quietHoursStart ?? existing row value, same for end and for quietHoursEnabled — so it fires even when you pass only one bound that happens to equal the stored other bound, and only when quiet hours are effectively enabled. There is deliberately no assertCompatibleDeliveryMode call here (see the in-source comment about pre-migration rows).

Solutions

  1. Pick different bounds — an overnight window like start 22, end 7 is valid (end < start crosses midnight).
  2. To disable the window, pass quietHoursEnabled: false instead of collapsing start and end to the same hour.
  3. When patching a single bound, first read the stored rule (getAlertRules) and ensure the resulting pair differs.
  4. Send both bounds explicitly on enable-flows so the effective pair is deterministic.

Example fix

// before
await mutateAPI.alertRules.setQuietHours({ variant: 'default', quietHoursEnabled: true, quietHoursStart: 22, quietHoursEnd: 22 });
// ConvexError: quietHoursStart and quietHoursEnd must differ (same value = no quiet window)

// after — overnight window; or disable when no window is wanted
await mutateAPI.alertRules.setQuietHours({ variant: 'default', quietHoursEnabled: true, quietHoursStart: 22, quietHoursEnd: 7 });
// no window at all:
await mutateAPI.alertRules.setQuietHours({ variant: 'default', quietHoursEnabled: false });
Defensive patterns

Strategy: validation

Validate before calling

// Compare EFFECTIVE bounds (arg ?? stored) exactly like the server.
const effectiveStart = form.start ?? storedRule?.quietHoursStart;
const effectiveEnd = form.end ?? storedRule?.quietHoursEnd;
const effectiveEnabled = form.enabled ?? storedRule?.quietHoursEnabled ?? false;
if (effectiveEnabled && effectiveStart !== undefined && effectiveEnd !== undefined && effectiveStart === effectiveEnd) {
  setFormError('Start and end must differ; disable quiet hours instead of matching them');
  return;
}
await mutateAPI.alertRules.setQuietHours(form);

Type guard

const isValidQuietWindow = (
  enabled: boolean,
  start: number | undefined,
  end: number | undefined,
): boolean => !enabled || start === undefined || end === undefined || start !== end;

Try / catch

try {
  await mutateAPI.alertRules.setQuietHours(args);
} catch (err) {
  if (err instanceof ConvexError && typeof err.data === 'string' && err.data.includes('must differ')) {
    setFormError('Quiet hours need different start and end times (e.g. 22:00 to 07:00)');
    return;
  }
  throw err;
}

Prevention

When it happens

Trigger: setQuietHours with quietHoursEnabled true and quietHoursStart === quietHoursEnd (e.g. both 22); enabling quiet hours (quietHoursEnabled: true) on a row whose stored start and end are already equal; patching only quietHoursEnd to a value equal to the stored quietHoursStart.

Common situations: Forms defaulting both selects to the same hour (22/22); a user attempting 'do not disturb all day' by setting start = end — the feature models this via quietHoursEnabled, not equal bounds; partial updates that unknowingly collide with stored values.

Related errors


AI-assisted analysis of koala73/worldmonitor@eeab0a219f (2026-08-21). Data as JSON: /api/errors/8f34623b65defe43. Report an issue: GitHub.

Appendix: source

Thrown at convex/alertRules.ts:461

    if (!identity) throw new ConvexError("UNAUTHENTICATED");
    const userId = identity.subject;
    await assertProEntitlement(ctx, userId);
    validateQuietHoursArgs(args);

    const existing = await ctx.db
      .query("alertRules")
      .withIndex("by_user_variant", (q) =>
        q.eq("userId", userId).eq("variant", args.variant),
      )
      .unique();

    // Only enforce start !== end when quiet hours are effectively enabled
    const effectiveEnabled = args.quietHoursEnabled ?? existing?.quietHoursEnabled ?? false;
    if (effectiveEnabled) {
      const effectiveStart = args.quietHoursStart ?? existing?.quietHoursStart;
      const effectiveEnd = args.quietHoursEnd ?? existing?.quietHoursEnd;
      if (effectiveStart !== undefined && effectiveEnd !== undefined && effectiveStart === effectiveEnd) {
        throw new ConvexError("quietHoursStart and quietHoursEnd must differ (same value = no quiet window)");
      }
    }

    // resolveEffectivePair supplies sensitivity:'critical' on fresh insert (compatible
    // by construction under the tightened rule). We DO NOT call assertCompatibleDeliveryMode here — quiet-hours
    // mutations don't touch the (digestMode, sensitivity) pair, so blocking unrelated
    // quiet-hours updates on pre-migration forbidden rows would surface as confusing
    // generic 500s ('set-quiet-hours' HTTP action has no INCOMPATIBLE_DELIVERY
    // passthrough). The relay coerce-at-read protects delivery for in-flight forbidden
    // rows; the migration drains them.
    // See docs/archive/plans/forbid-realtime-all-events.md + PR #3461 Greptile P1.
    const pair = resolveEffectivePair({ existing: existing ?? undefined });

    const now = Date.now();
    const patch = {
      quietHoursEnabled: args.quietHoursEnabled,
      quietHoursStart: args.quietHoursStart,
      quietHoursEnd: args.quietHoursEnd,

View on GitHub (pinned to eeab0a219f)