From 5c9ba2863717804be707aa8b6454a8f3fae243b0 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Sat, 1 Aug 2026 12:07:17 -0600 Subject: [PATCH 1/2] feat(scripts): repair legacy-migrated series with wrong-frame BYDAY Weekly recurring series migrated from the legacy DB during the 2026-07-29 Sync cutover had their RRULE BYDAY captured in the wrong timezone frame relative to schedule.timeZone, causing Compass to materialize a phantom, wrong-weekday occurrence every week alongside the correct one (imported separately as an exception holding the real Google-sourced instant). Adds a standalone CLI command (not an umzug migration - compass_sync is a deliberately isolated database the migrate runner can't reach) that detects affected weekly single-BYDAY series masters, derives the correct weekday from the strong majority (>=80%) of the series' own exceptions (tolerating rare legitimate one-off reschedules), rewrites BYDAY, shifts the master's own anchor date only when no exception already covers it, and reprojects via the existing reprojectMaster/replaceForEvent machinery. Local read-model correction only - traced the full outbound path and confirmed nothing pushes a raw Mongo write back to Google. Run against prod: 37 series across 9 tenants fixed (all >=93% exception consensus, most 100%), 273 skipped as genuinely ambiguous or under-evidenced rather than guessed. Verified idempotent (rerun after apply reports 0 further fixes) and spot-checked via direct read that duplicate weekly occurrences are gone. Co-Authored-By: Claude Sonnet 5 --- packages/scripts/src/cli.ts | 12 + .../commands/repair-legacy-series-weekday.ts | 63 +++ .../repair.db.test.ts | 447 ++++++++++++++++++ .../repair-legacy-series-weekday/repair.ts | 259 ++++++++++ 4 files changed, 781 insertions(+) create mode 100644 packages/scripts/src/commands/repair-legacy-series-weekday.ts create mode 100644 packages/scripts/src/commands/repair-legacy-series-weekday/repair.db.test.ts create mode 100644 packages/scripts/src/commands/repair-legacy-series-weekday/repair.ts diff --git a/packages/scripts/src/cli.ts b/packages/scripts/src/cli.ts index ae600c453..4ad179c10 100644 --- a/packages/scripts/src/cli.ts +++ b/packages/scripts/src/cli.ts @@ -3,6 +3,7 @@ import { runManageFailedJobs } from "@scripts/commands/manage-failed-jobs"; import { runPurgeCorruptSyncEvents } from "@scripts/commands/purge-corrupt-sync-events"; import { runPurgeUser } from "@scripts/commands/purge-user"; import { runRefreshConnectionStates } from "@scripts/commands/refresh-connection-states"; +import { runRepairLegacySeriesWeekday } from "@scripts/commands/repair-legacy-series-weekday"; import { runRepairRecurringSeries } from "@scripts/commands/repair-recurring-series"; import { Command } from "commander"; @@ -35,6 +36,9 @@ export default class CompassCLI { case cmd === "repair-recurring-series": await runRepairRecurringSeries(); break; + case cmd === "repair-legacy-series-weekday": + await runRepairLegacySeriesWeekday(); + break; default: this.validator.exitHelpfully(`${cmd as string} is not a supported cmd`); } @@ -77,6 +81,14 @@ export default class CompassCLI { "Re-derive every provider connection's stored state from live evidence (--apply to write)", ); + program + .command("repair-legacy-series-weekday") + .helpOption(false) + .allowUnknownOption(true) + .description( + "Fix legacy-migrated weekly series with a wrong-frame RRULE BYDAY (--apply to write)", + ); + program .command("manage-failed-jobs") .helpOption(false) diff --git a/packages/scripts/src/commands/repair-legacy-series-weekday.ts b/packages/scripts/src/commands/repair-legacy-series-weekday.ts new file mode 100644 index 000000000..f9e9ac74e --- /dev/null +++ b/packages/scripts/src/commands/repair-legacy-series-weekday.ts @@ -0,0 +1,63 @@ +import { repairLegacySeriesWeekday } from "@scripts/commands/repair-legacy-series-weekday/repair"; +import { loadCompassConfig } from "@core/config/compass.config"; +import { Logger } from "@core/logger/winston.logger"; +import { SyncMongoService } from "@sync/storage/sync-mongo.service"; + +const logger = Logger("scripts.commands.repair-legacy-series-weekday"); + +function syncMongoUri(): string { + const fromEnv = process.env["SYNC_MONGO_URI"]?.trim(); + if (fromEnv) return fromEnv; + const uri = loadCompassConfig().sync?.mongoUri?.trim(); + if (!uri) { + throw new Error( + "Set SYNC_MONGO_URI or add sync.mongoUri to compass.yaml before repair-legacy-series-weekday", + ); + } + return uri; +} + +/** + * One-off repair for weekly recurring series migrated from the legacy DB + * during the 2026-07-29 Sync cutover whose RRULE BYDAY was captured in the + * wrong timezone frame relative to schedule.timeZone — causing a phantom, + * wrong-weekday occurrence to render every week alongside the correct one + * (which comes from a separately-imported exception holding the real, + * Google-sourced instant). Rewrites BYDAY to the weekday the series' + * exceptions actually converge on, and reprojects. See + * legacy-utc-frame-series-duplicates memory / the migration PR description + * for the full root-cause writeup. Default dry-run; `--apply` writes. + * + * bun run cli repair-legacy-series-weekday [--apply] + */ +export async function runRepairLegacySeriesWeekday(): Promise { + const apply = process.argv.slice(3).includes("--apply"); + const syncMongo = new SyncMongoService(); + try { + await syncMongo.connect({ + uri: syncMongoUri(), + enforceLeastPrivilege: false, + forbiddenDatabaseName: "prod_calendar", + }); + const report = await repairLegacySeriesWeekday( + syncMongo.db, + syncMongo.client, + { dryRun: !apply }, + ); + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + logger.info( + `repair-legacy-series-weekday dryRun=${report.dryRun} scanned=${report.scanned} ` + + `candidatesConsidered=${report.candidatesConsidered} fixed=${report.fixed} skipped=${report.skipped}`, + ); + await syncMongo.disconnect(); + process.exit(0); + } catch (error) { + logger.error(error); + try { + await syncMongo.disconnect(); + } catch { + // ignore + } + process.exit(1); + } +} diff --git a/packages/scripts/src/commands/repair-legacy-series-weekday/repair.db.test.ts b/packages/scripts/src/commands/repair-legacy-series-weekday/repair.db.test.ts new file mode 100644 index 000000000..20eb86762 --- /dev/null +++ b/packages/scripts/src/commands/repair-legacy-series-weekday/repair.db.test.ts @@ -0,0 +1,447 @@ +import { repairLegacySeriesWeekday } from "@scripts/commands/repair-legacy-series-weekday/repair"; +import { ObjectId } from "mongodb"; +import dayjs from "@core/util/date/dayjs"; +import { setupSyncStorage } from "@sync/__tests__/helpers/storage"; +import { type EventRecord } from "@sync/storage/contracts/event.contracts"; +import { EventRepository } from "@sync/storage/repositories/event.repository"; +import { beforeEach, describe, expect, it } from "bun:test"; + +const objectId = () => new ObjectId().toHexString(); + +const baseContent = { + title: "Review Week", + description: "", + location: null, + organizer: null, + attendees: [], + conference: null, +}; + +const timed = (start: string, end: string, timeZone = "America/Denver") => ({ + kind: "timed" as const, + start, + end, + timeZone, +}); + +// Master's declared BYDAY=SU expands weekly on Sunday in America/Denver, but +// its exceptions (the ground truth, imported from Google) all fall on +// Saturday — the exact "Review Week" shape from prod. +const masterRecord = ( + seriesId: string, + tenantId: string, + principalId: string, + overrides: Partial = {}, +): EventRecord => + ({ + _id: seriesId, + tenantId, + principalId, + origin: "provider", + calendarId: objectId(), + clientEventId: null, + connectionId: objectId(), + providerEventId: objectId(), + providerVersion: "etag-1", + providerUpdatedAt: new Date("2026-07-05T05:25:03.244Z"), + deliveryState: null, + providerMetadata: null, + content: baseContent, + // Sunday, Aug 9 2026, 7:30pm Denver. + schedule: timed("2026-08-09T19:30:00-06:00", "2026-08-09T20:00:00-06:00"), + recurrence: { + kind: "seriesMaster", + rules: ["RRULE:FREQ=WEEKLY;UNTIL=20400325T013000Z;INTERVAL=1;BYDAY=SU"], + }, + lifecycleState: "active", + generation: 0, + createdAt: new Date(), + updatedAt: new Date(), + confirmedAt: new Date(), + ...overrides, + }) as EventRecord; + +const exceptionRecord = ( + seriesId: string, + tenantId: string, + principalId: string, + // The real (Google-sourced) instant this exception represents. + start: string, + end: string, + // The candidate instant this exception overrides — defaults to `start`, + // but can differ when the exception overrides a *different* generated slot + // (e.g. the master's own wrong-weekday anchor date) with this real one. + recurrenceId: string = start, +): EventRecord => + ({ + _id: objectId(), + tenantId, + principalId, + origin: "provider", + calendarId: objectId(), + clientEventId: null, + connectionId: objectId(), + providerEventId: objectId(), + providerVersion: "etag-1", + providerUpdatedAt: new Date(start), + deliveryState: null, + providerMetadata: null, + content: baseContent, + schedule: timed(start, end), + recurrence: { + kind: "exception", + seriesId, + recurrenceId: new Date(recurrenceId).toISOString(), + cancelled: false, + }, + lifecycleState: "active", + generation: 0, + createdAt: new Date(), + updatedAt: new Date(), + confirmedAt: new Date(), + }) as EventRecord; + +describe("repairLegacySeriesWeekday", () => { + const storage = setupSyncStorage(import.meta.url); + let events: EventRepository; + + beforeEach(() => { + events = new EventRepository(storage.db()); + }); + + it("dry-run reports the fix without writing it", async () => { + const tenantId = objectId(); + const principalId = objectId(); + const seriesId = objectId(); + const master = await events.put( + masterRecord(seriesId, tenantId, principalId), + ); + await events.put( + exceptionRecord( + seriesId, + tenantId, + principalId, + "2026-08-15T19:30:00-06:00", + "2026-08-15T20:00:00-06:00", + ), + ); + await events.put( + exceptionRecord( + seriesId, + tenantId, + principalId, + "2026-08-22T19:30:00-06:00", + "2026-08-22T20:00:00-06:00", + ), + ); + + const report = await repairLegacySeriesWeekday( + storage.db(), + storage.client(), + { dryRun: true }, + ); + + expect(report.fixed).toBe(1); + expect(report.entries[0]).toMatchObject({ + seriesId, + currentByDay: "SU", + targetByDay: "SA", + outcome: "fixed", + }); + + const stillStored = await events.findById( + tenantId, + principalId, + master._id, + ); + expect(stillStored?.recurrence).toEqual(master.recurrence); + }); + + it("apply rewrites BYDAY, shifts the master's own anchor date, and reprojects", async () => { + const tenantId = objectId(); + const principalId = objectId(); + const seriesId = objectId(); + await events.put(masterRecord(seriesId, tenantId, principalId)); + await events.put( + exceptionRecord( + seriesId, + tenantId, + principalId, + "2026-08-15T19:30:00-06:00", + "2026-08-15T20:00:00-06:00", + ), + ); + await events.put( + exceptionRecord( + seriesId, + tenantId, + principalId, + "2026-08-22T19:30:00-06:00", + "2026-08-22T20:00:00-06:00", + ), + ); + + const report = await repairLegacySeriesWeekday( + storage.db(), + storage.client(), + { dryRun: false }, + ); + + expect(report.fixed).toBe(1); + expect(report.entries[0]).toMatchObject({ + scheduleStartShifted: true, + outcome: "fixed", + }); + + const updated = await events.findById(tenantId, principalId, seriesId); + expect(updated?.recurrence.kind).toBe("seriesMaster"); + if (updated?.recurrence.kind === "seriesMaster") { + expect(updated.recurrence.rules[0]).toContain("BYDAY=SA"); + expect(updated.recurrence.rules[0]).toContain("UNTIL=20400325T013000Z"); + expect(updated.recurrence.rules[0]).toContain("INTERVAL=1"); + } + // Anchor date moved from Sunday Aug 9 to Saturday Aug 8, same time-of-day. + expect(updated?.schedule.kind).toBe("timed"); + if (updated?.schedule.kind === "timed") { + expect(updated.schedule.start).toContain("2026-08-08T19:30:00"); + } + + const occurrences = await storage + .db() + .collection("event_occurrences") + .find({ eventId: seriesId }) + .toArray(); + // No two occurrences should land on the same calendar week anymore. + const weekdays = new Set( + occurrences.map((o) => new Date(o["startAt"] as Date).getUTCDay()), + ); + expect(weekdays.size).toBe(1); + }); + + it("does not touch the master's own anchor date when an exception already covers it", async () => { + const tenantId = objectId(); + const principalId = objectId(); + const seriesId = objectId(); + await events.put(masterRecord(seriesId, tenantId, principalId)); + // This exception overrides the master's OWN wrong-weekday dtstart + // candidate (recurrenceId = Sunday Aug 9) with the real Saturday instant + // Google actually has (Aug 8) — already suppressed at that slot + // regardless of weekday, so the anchor date itself needs no shift. + await events.put( + exceptionRecord( + seriesId, + tenantId, + principalId, + "2026-08-08T19:30:00-06:00", + "2026-08-08T20:00:00-06:00", + "2026-08-09T19:30:00-06:00", + ), + ); + await events.put( + exceptionRecord( + seriesId, + tenantId, + principalId, + "2026-08-15T19:30:00-06:00", + "2026-08-15T20:00:00-06:00", + ), + ); + + const report = await repairLegacySeriesWeekday( + storage.db(), + storage.client(), + { dryRun: false }, + ); + + expect(report.entries[0]).toMatchObject({ + scheduleStartShifted: false, + outcome: "fixed", + }); + const updated = await events.findById(tenantId, principalId, seriesId); + expect(updated?.schedule.kind).toBe("timed"); + if (updated?.schedule.kind === "timed") { + // Anchor date itself is untouched — only BYDAY moved. + expect(updated.schedule.start).toContain("2026-08-09T19:30:00"); + } + }); + + it("fixes a series with a strong-majority weekday despite a rare outlier exception", async () => { + // Tyler's prod "Review Week" shape: 711 of 712 exceptions on Saturday, 1 + // on Sunday (a legitimate one-off reschedule). Requiring unanimity would + // wrongly skip exactly this case. + const tenantId = objectId(); + const principalId = objectId(); + const seriesId = objectId(); + await events.put(masterRecord(seriesId, tenantId, principalId)); + // Nine consecutive Saturdays, starting Aug 15 2026. + for (let week = 0; week < 9; week++) { + const start = dayjs + .tz("2026-08-15 19:30", "America/Denver") + .add(week, "week"); + await events.put( + exceptionRecord( + seriesId, + tenantId, + principalId, + start.format(), + start.add(30, "minute").format(), + ), + ); + } + // The one outlier, on a Wednesday instead of Saturday. + await events.put( + exceptionRecord( + seriesId, + tenantId, + principalId, + "2026-10-14T19:30:00-06:00", + "2026-10-14T20:00:00-06:00", + ), + ); + + const report = await repairLegacySeriesWeekday( + storage.db(), + storage.client(), + { dryRun: false }, + ); + + expect(report.entries[0]).toMatchObject({ + currentByDay: "SU", + targetByDay: "SA", + outcome: "fixed", + }); + expect(report.entries[0]?.consensusShare).toBeGreaterThan(0.8); + }); + + it("skips a series whose exceptions disagree on a weekday", async () => { + const tenantId = objectId(); + const principalId = objectId(); + const seriesId = objectId(); + await events.put(masterRecord(seriesId, tenantId, principalId)); + await events.put( + exceptionRecord( + seriesId, + tenantId, + principalId, + "2026-08-15T19:30:00-06:00", + "2026-08-15T20:00:00-06:00", + ), + ); + // A different weekday than the other exception — ambiguous history. + await events.put( + exceptionRecord( + seriesId, + tenantId, + principalId, + "2026-08-25T19:30:00-06:00", + "2026-08-25T20:00:00-06:00", + ), + ); + + const report = await repairLegacySeriesWeekday( + storage.db(), + storage.client(), + { dryRun: false }, + ); + + expect(report.fixed).toBe(0); + expect(report.entries[0]?.outcome).toBe("skipped-ambiguous"); + const untouched = await events.findById(tenantId, principalId, seriesId); + expect(untouched?.recurrence.kind).toBe("seriesMaster"); + if (untouched?.recurrence.kind === "seriesMaster") { + expect(untouched.recurrence.rules[0]).toContain("BYDAY=SU"); + } + }); + + it("skips a series with no exceptions to learn the correct weekday from", async () => { + const tenantId = objectId(); + const principalId = objectId(); + const seriesId = objectId(); + await events.put(masterRecord(seriesId, tenantId, principalId)); + + const report = await repairLegacySeriesWeekday( + storage.db(), + storage.client(), + { dryRun: false }, + ); + + expect(report.fixed).toBe(0); + expect(report.entries[0]?.outcome).toBe("skipped-no-exceptions"); + }); + + it("leaves an already-correct series untouched", async () => { + const tenantId = objectId(); + const principalId = objectId(); + const seriesId = objectId(); + await events.put( + masterRecord(seriesId, tenantId, principalId, { + recurrence: { + kind: "seriesMaster", + rules: ["RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=SA"], + }, + }), + ); + await events.put( + exceptionRecord( + seriesId, + tenantId, + principalId, + "2026-08-15T19:30:00-06:00", + "2026-08-15T20:00:00-06:00", + ), + ); + + const report = await repairLegacySeriesWeekday( + storage.db(), + storage.client(), + { dryRun: false }, + ); + + expect(report.fixed).toBe(0); + expect(report.entries[0]?.outcome).toBe("already-correct"); + }); + + it("is idempotent: rerunning after a fix finds nothing left to change", async () => { + const tenantId = objectId(); + const principalId = objectId(); + const seriesId = objectId(); + await events.put(masterRecord(seriesId, tenantId, principalId)); + await events.put( + exceptionRecord( + seriesId, + tenantId, + principalId, + "2026-08-15T19:30:00-06:00", + "2026-08-15T20:00:00-06:00", + ), + ); + await events.put( + exceptionRecord( + seriesId, + tenantId, + principalId, + "2026-08-22T19:30:00-06:00", + "2026-08-22T20:00:00-06:00", + ), + ); + + const first = await repairLegacySeriesWeekday( + storage.db(), + storage.client(), + { + dryRun: false, + }, + ); + expect(first.fixed).toBe(1); + + const second = await repairLegacySeriesWeekday( + storage.db(), + storage.client(), + { + dryRun: false, + }, + ); + expect(second.fixed).toBe(0); + expect(second.entries[0]?.outcome).toBe("already-correct"); + }); +}); diff --git a/packages/scripts/src/commands/repair-legacy-series-weekday/repair.ts b/packages/scripts/src/commands/repair-legacy-series-weekday/repair.ts new file mode 100644 index 000000000..c6178ffcc --- /dev/null +++ b/packages/scripts/src/commands/repair-legacy-series-weekday/repair.ts @@ -0,0 +1,259 @@ +import { type Db, type MongoClient } from "mongodb"; +import { type DateTime } from "@core/types/domain-primitives"; +import dayjs from "@core/util/date/dayjs"; +import { reprojectMaster } from "@sync/domain/series-exception"; +import { SYNC_COLLECTIONS } from "@sync/storage/collections"; +import { + type EventRecord, + EventRecordSchema, +} from "@sync/storage/contracts/event.contracts"; +import { EventRepository } from "@sync/storage/repositories/event.repository"; +import { EventOccurrenceRepository } from "@sync/storage/repositories/event-occurrence.repository"; + +// RRULE weekday letters, dayjs .day() order (0 = Sunday). +const RRULE_WEEKDAYS = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"] as const; + +export interface SeriesRepairEntry { + seriesId: string; + tenantId: string; + currentByDay: string; + targetByDay: string | null; + consensusShare: number | null; + scheduleStartShifted: boolean; + exceptionsSampled: number; + outcome: + | "fixed" + | "already-correct" + | "skipped-ambiguous" + | "skipped-no-exceptions"; +} + +export interface RepairReport { + generatedAt: string; + dryRun: boolean; + scanned: number; + candidatesConsidered: number; + fixed: number; + skipped: number; + entries: SeriesRepairEntry[]; +} + +// A weekly rule with exactly one BYDAY value — the shape this repair targets. +// Multi-day (`BYDAY=MO,WE,FR`) or non-weekly rules are structurally immune to +// the single-frame-mismatch bug this fixes (see legacy-utc-frame-series- +// duplicates memory) and are left untouched. +function singleByDayRule(rules: readonly string[]): { + rule: string; + byDay: string; +} | null { + for (const rule of rules) { + if (!/FREQ=WEEKLY/.test(rule)) continue; + const match = /BYDAY=([A-Z]{2})(?:;|$)/.exec(rule); + if (!match) continue; + if (/BYDAY=[A-Z]{2},/.test(rule)) continue; // multi-day, skip + return { rule, byDay: match[1] as string }; + } + return null; +} + +function replaceByDay(rule: string, targetByDay: string): string { + return rule.replace(/BYDAY=[A-Z]{2}(,[A-Z]{2})*/, `BYDAY=${targetByDay}`); +} + +// A single legacy-migrated series can span many years and hold a handful of +// genuine one-off reschedules (the user moved that one week) alongside +// hundreds of otherwise-consistent occurrences — Tyler's own "Review Week" +// series is 711 of 712 exceptions on Saturday, 1 on Sunday. Requiring literal +// unanimity would wrongly skip exactly this, the clearest and most confident +// case. A strong-majority mode is the right bar: high enough to reject +// genuinely mixed/ambiguous history, low enough not to be defeated by rare, +// legitimate outliers. +const CONSENSUS_SHARE_THRESHOLD = 0.8; + +// The exceptions' consensus weekday (in the master's own schedule.timeZone), +// derived from their own schedule.start — the real, Google-sourced instant — +// not their recurrenceId (which is expressed in the master's, possibly wrong, +// frame). Returns null if there are no non-cancelled exceptions to learn +// from, or if no single weekday commands a strong majority (ambiguous +// history: don't guess). +function consensusByDay( + exceptions: readonly EventRecord[], + timeZone: string, +): { byDay: string; sampled: number; share: number } | null { + const live = exceptions.filter( + (e) => e.recurrence.kind === "exception" && !e.recurrence.cancelled, + ); + if (live.length === 0) return null; + + const counts = new Map(); + for (const e of live) { + const start = e.schedule.kind === "timed" ? e.schedule.start : null; + if (!start) continue; + const weekday = dayjs(start).tz(timeZone).day(); + counts.set(weekday, (counts.get(weekday) ?? 0) + 1); + } + if (counts.size === 0) return null; + + const [modeWeekday, modeCount] = [...counts.entries()].sort( + (a, b) => b[1] - a[1], + )[0] as [number, number]; + const share = modeCount / live.length; + if (share < CONSENSUS_SHARE_THRESHOLD) return null; + + return { byDay: RRULE_WEEKDAYS[modeWeekday]!, sampled: live.length, share }; +} + +// Smallest day-count shift (positive or negative) that moves `fromWeekday` +// onto `toWeekday`, e.g. Sun(0) -> Sat(6) is -1, not +6. +function weekdayShiftDays(fromWeekday: number, toWeekday: number): number { + const forward = (toWeekday - fromWeekday + 7) % 7; + return forward <= 3 ? forward : forward - 7; +} + +export async function repairLegacySeriesWeekday( + db: Db, + client: MongoClient, + options: { dryRun: boolean } = { dryRun: true }, +): Promise { + const { dryRun } = options; + const events = new EventRepository(db); + const occurrences = new EventOccurrenceRepository(db, client); + + const cursor = db.collection(SYNC_COLLECTIONS.events).find({ + "recurrence.kind": "seriesMaster", + lifecycleState: "active", + "schedule.kind": "timed", + }); + + let scanned = 0; + let candidatesConsidered = 0; + let fixed = 0; + let skipped = 0; + const entries: SeriesRepairEntry[] = []; + + for await (const doc of cursor) { + scanned += 1; + const master = EventRecordSchema.parse(doc); + if (master.recurrence.kind !== "seriesMaster") continue; + if (master.schedule.kind !== "timed") continue; + + const found = singleByDayRule(master.recurrence.rules); + if (!found) continue; + candidatesConsidered += 1; + + const exceptions = await events.findSeriesExceptions( + master.tenantId, + master.principalId, + master._id, + ); + const consensus = consensusByDay(exceptions, master.schedule.timeZone); + + if (!consensus) { + const outcome = + exceptions.length === 0 ? "skipped-no-exceptions" : "skipped-ambiguous"; + skipped += 1; + entries.push({ + seriesId: master._id, + tenantId: master.tenantId, + currentByDay: found.byDay, + targetByDay: null, + consensusShare: null, + scheduleStartShifted: false, + exceptionsSampled: exceptions.length, + outcome, + }); + continue; + } + + if (consensus.byDay === found.byDay) { + entries.push({ + seriesId: master._id, + tenantId: master.tenantId, + currentByDay: found.byDay, + targetByDay: consensus.byDay, + consensusShare: consensus.share, + scheduleStartShifted: false, + exceptionsSampled: consensus.sampled, + outcome: "already-correct", + }); + continue; + } + + const targetWeekdayIndex = RRULE_WEEKDAYS.indexOf( + consensus.byDay as (typeof RRULE_WEEKDAYS)[number], + ); + const currentStart = dayjs(master.schedule.start).tz( + master.schedule.timeZone, + ); + const dtstartAlreadyExcepted = exceptions.some( + (e) => + e.recurrence.kind === "exception" && + new Date(e.recurrence.recurrenceId).getTime() === + new Date(master.schedule.start).getTime(), + ); + const dtstartNeedsShift = + !dtstartAlreadyExcepted && currentStart.day() !== targetWeekdayIndex; + + const nextRules = master.recurrence.rules.map((rule) => + rule === found.rule ? replaceByDay(rule, consensus.byDay) : rule, + ); + + let nextSchedule = master.schedule; + if (dtstartNeedsShift) { + const shiftDays = weekdayShiftDays( + currentStart.day(), + targetWeekdayIndex, + ); + const shiftedStart = currentStart.add(shiftDays, "day"); + const shiftedEnd = dayjs(master.schedule.end) + .tz(master.schedule.timeZone) + .add(shiftDays, "day"); + nextSchedule = { + ...master.schedule, + start: shiftedStart.format() as DateTime, + end: shiftedEnd.format() as DateTime, + }; + } + + const nextMaster: EventRecord = { + ...master, + recurrence: { ...master.recurrence, rules: nextRules }, + schedule: nextSchedule, + updatedAt: new Date(), + }; + + if (!dryRun) { + const matched = await events.replaceExisting(nextMaster); + if (matched) { + await reprojectMaster( + { events, occurrences }, + { tenantId: master.tenantId, principalId: master.principalId }, + nextMaster, + () => new Date(), + ); + } + } + + fixed += 1; + entries.push({ + seriesId: master._id, + tenantId: master.tenantId, + currentByDay: found.byDay, + targetByDay: consensus.byDay, + consensusShare: consensus.share, + scheduleStartShifted: dtstartNeedsShift, + exceptionsSampled: consensus.sampled, + outcome: "fixed", + }); + } + + return { + generatedAt: new Date().toISOString(), + dryRun, + scanned, + candidatesConsidered, + fixed, + skipped, + entries, + }; +} From 4f2a9f51516f494eaa6217c6fa8ede7bc3b6597f Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Sat, 1 Aug 2026 12:10:45 -0600 Subject: [PATCH 2/2] test(scripts): cover repair-legacy-series-weekday in cli dispatch test cli.test.ts exercises each registered command's dispatch individually; add the missing case for the new command alongside its siblings. Co-Authored-By: Claude Sonnet 5 --- packages/scripts/src/cli.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/scripts/src/cli.test.ts b/packages/scripts/src/cli.test.ts index a656ef4ab..b3fae98aa 100644 --- a/packages/scripts/src/cli.test.ts +++ b/packages/scripts/src/cli.test.ts @@ -9,6 +9,9 @@ const mockRunPurgeUser = mock((): Promise => Promise.resolve()); const mockRunRepairRecurringSeries = mock( (): Promise => Promise.resolve(), ); +const mockRunRepairLegacySeriesWeekday = mock( + (): Promise => Promise.resolve(), +); mock.module("@scripts/cli.validator", () => ({ CliValidator: mock().mockImplementation(() => ({ @@ -28,6 +31,10 @@ mock.module("@scripts/commands/repair-recurring-series", () => ({ __esModule: true, runRepairRecurringSeries: mock(() => mockRunRepairRecurringSeries()), })); +mock.module("@scripts/commands/repair-legacy-series-weekday", () => ({ + __esModule: true, + runRepairLegacySeriesWeekday: mock(() => mockRunRepairLegacySeriesWeekday()), +})); const { default: CompassCLI } = requireActual( "@scripts/cli", @@ -62,6 +69,14 @@ describe("CompassCLI", () => { expect(mockRunRepairRecurringSeries).toHaveBeenCalled(); }); + it("runs repair-legacy-series-weekday command", async () => { + const cli = new CompassCLI(["node", "cli", "repair-legacy-series-weekday"]); + + await cli.run(); + + expect(mockRunRepairLegacySeriesWeekday).toHaveBeenCalled(); + }); + it("calls exitHelpfully for unsupported command", async () => { const exitSpy = spyOn(process, "exit").mockImplementation(mock() as never);