From 951e48be687ef5640ec0cdeb0b9e3b97dff9b84a Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Sat, 1 Aug 2026 12:17:36 -0600 Subject: [PATCH] chore(scripts): retire repair-legacy-series-weekday tooling The one-off repair (PR #2535) has run successfully against prod: 37 legacy-migrated weekly series fixed across 9 tenants (all >=93% exception consensus, 35 of 37 at 100%), verified via direct DB reads and an idempotent rerun reporting 0 further changes. The population it targeted is closed - the current write/import paths are frame-consistent and cannot reproduce it - so the command has nothing left to do and no self-hosting relevance. Mirrors how the 2026-07-29 Sync-cutover one-off tooling (migrate-provider-state.ts, preseed-sync.ts, etc.) was deleted the day after confirmed use (commit 07816a841), rather than accumulating as permanent dead code. The full before/after data and methodology are preserved in PR #2535's description. Co-Authored-By: Claude Sonnet 5 --- packages/scripts/src/cli.test.ts | 15 - 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 ---------- 5 files changed, 796 deletions(-) delete mode 100644 packages/scripts/src/commands/repair-legacy-series-weekday.ts delete mode 100644 packages/scripts/src/commands/repair-legacy-series-weekday/repair.db.test.ts delete mode 100644 packages/scripts/src/commands/repair-legacy-series-weekday/repair.ts diff --git a/packages/scripts/src/cli.test.ts b/packages/scripts/src/cli.test.ts index b3fae98aa..a656ef4ab 100644 --- a/packages/scripts/src/cli.test.ts +++ b/packages/scripts/src/cli.test.ts @@ -9,9 +9,6 @@ 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(() => ({ @@ -31,10 +28,6 @@ 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", @@ -69,14 +62,6 @@ 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); diff --git a/packages/scripts/src/cli.ts b/packages/scripts/src/cli.ts index 4ad179c10..ae600c453 100644 --- a/packages/scripts/src/cli.ts +++ b/packages/scripts/src/cli.ts @@ -3,7 +3,6 @@ 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"; @@ -36,9 +35,6 @@ 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`); } @@ -81,14 +77,6 @@ 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 deleted file mode 100644 index f9e9ac74e..000000000 --- a/packages/scripts/src/commands/repair-legacy-series-weekday.ts +++ /dev/null @@ -1,63 +0,0 @@ -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 deleted file mode 100644 index 20eb86762..000000000 --- a/packages/scripts/src/commands/repair-legacy-series-weekday/repair.db.test.ts +++ /dev/null @@ -1,447 +0,0 @@ -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 deleted file mode 100644 index c6178ffcc..000000000 --- a/packages/scripts/src/commands/repair-legacy-series-weekday/repair.ts +++ /dev/null @@ -1,259 +0,0 @@ -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, - }; -}