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

Thrown in setQuietHours when quiet hours are effectively enabled (args.quietHoursEnabled ?? existing?.quietHoursEnabled ?? false is true) and the effective start and end hours resolve to the same value. An identical start/end denotes a zero-length window, which is treated as 'no quiet window' and rejected rather than silently stored. The check uses effective values — args override existing — so it can fire even when only one of the two is supplied.

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 ffec79ac33)

Solutions

  1. Ensure the start and end hours differ when enabling quiet hours.
  2. When patching only one of start/end, verify the other (stored) value differs from the new one before submitting.
  3. If the user genuinely wants quiet hours disabled, set quietHoursEnabled:false instead of equal start/end.

Example fix

// before
await setQuietHours({ variant, quietHoursEnabled: true, quietHoursStart: 22, quietHoursEnd: 22 });
// after
await setQuietHours({ variant, quietHoursEnabled: true, quietHoursStart: 22, quietHoursEnd: 7 });
Defensive patterns

Strategy: validation

Validate before calling

function willQuietHoursBeEnabled(args, existing) {
  return args.quietHoursEnabled ?? existing?.quietHoursEnabled ?? false;
}
function effectiveStartEnd(args, existing) {
  return {
    start: args.quietHoursStart ?? existing?.quietHoursStart,
    end: args.quietHoursEnd ?? existing?.quietHoursEnd,
  };
}
// before submit:
if (willQuietHoursBeEnabled(args, existing)) {
  const { start, end } = effectiveStartEnd(args, existing);
  if (start !== undefined && end !== undefined && start === end) {
    // surface 'start and end must differ'
  }
}

Type guard

function isValidQuietWindow(args, existing): boolean {
  const enabled = args.quietHoursEnabled ?? existing?.quietHoursEnabled ?? false;
  if (!enabled) return true;
  const start = args.quietHoursStart ?? existing?.quietHoursStart;
  const end = args.quietHoursEnd ?? existing?.quietHoursEnd;
  if (start === undefined || end === undefined) return true;
  return start !== end;
}

Try / catch

try {
  await setQuietHours(args);
} catch (e) {
  if (e instanceof ConvexError && /must differ/.test(String(e.message))) {
    // prompt user to pick a distinct end hour
  } else throw e;
}

Prevention

When it happens

Trigger: Calling setQuietHours with quietHoursEnabled:true and quietHoursStart === quietHoursEnd (both 7, both 22, etc.); calling with quietHoursEnabled:true and only quietHoursStart supplied when the stored quietHoursEnd equals the new start; calling with quietHoursEnabled:true and only quietHoursEnd supplied when the stored quietHoursStart equals the new end.

Common situations: UI defaulting both fields to the same hour; a copy/clone operation that duplicates the start into end; a rounding step that collapses both to the same value; partial update that happens to collide with the stored counterpart.

Related errors


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