From bd059da94a3027d7a196a5483067c398107224be Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 7 May 2026 12:33:03 -0400 Subject: [PATCH] refactor(maintenance): tighten validator + migration per /simplify - Collapse two refines into a single superRefine in createMaintenanceWindowBodyValidation; same on edit. - Match peer migration convention: use mongoose.connection.db directly, not Model.collection. - Tighten TTL index detection: require expireAfterSeconds + single-field key. - Add Starting log at migration top to match peers 0003-0006. --- .../0007_dropMaintenanceWindowExpiryIndex.ts | 30 +++++++++----- .../validation/maintenanceWindowValidation.ts | 39 ++++++++++++++----- 2 files changed, 49 insertions(+), 20 deletions(-) diff --git a/server/src/db/migration/0007_dropMaintenanceWindowExpiryIndex.ts b/server/src/db/migration/0007_dropMaintenanceWindowExpiryIndex.ts index 850ab19ef..38e55c0d7 100644 --- a/server/src/db/migration/0007_dropMaintenanceWindowExpiryIndex.ts +++ b/server/src/db/migration/0007_dropMaintenanceWindowExpiryIndex.ts @@ -1,23 +1,33 @@ -import { MaintenanceWindowModel } from "../models/MaintenanceWindow.js"; +import mongoose from "mongoose"; import { logger } from "@/utils/logger.js"; /** - * Drops the TTL index on MaintenanceWindow.expiry and unsets the expiry field. + * Drops the TTL index on MaintenanceWindow.expiry and unsets the field on existing rows. * - * Earlier versions auto-set expiry = end for one-time maintenance windows. Combined with the - * TTL index ({ expires: "0s" }), this caused Mongo to silently delete completed one-time - * windows shortly after their end-date passed — including windows whose end-date was already - * in the past at creation time (e.g. timezone-mismatched payloads from automation tools). - * The fix stops writing expiry on create, and this migration removes the index and stale field - * so legacy rows are no longer at risk of being deleted by the TTL monitor. + * Why: a previous version copied `end` into `expiry` for one-time windows. Combined with the + * TTL index on `expiry`, this caused Mongo to silently delete rows whose `end` was in the past + * at creation time (a common symptom for automation tools sending non-UTC timestamps). The + * fix stops writing `expiry`; this migration removes the live exposure on existing installs. */ export async function dropMaintenanceWindowExpiryIndex(): Promise { const SERVICE_NAME = "Migration:DropMaintenanceWindowExpiryIndex"; try { - const collection = MaintenanceWindowModel.collection; + logger.info({ + service: SERVICE_NAME, + message: "Starting drop of MaintenanceWindow.expiry TTL index", + }); + + const db = mongoose.connection.db; + if (!db) { + throw new Error("Mongo connection not initialized"); + } + const collection = db.collection("maintenancewindows"); + const indexes = await collection.indexes(); - const expiryIndex = indexes.find((idx) => idx.key && idx.key.expiry === 1); + const expiryIndex = indexes.find( + (idx) => idx.expireAfterSeconds !== undefined && idx.key && Object.keys(idx.key).length === 1 && idx.key.expiry === 1 + ); if (expiryIndex?.name) { await collection.dropIndex(expiryIndex.name); diff --git a/server/src/validation/maintenanceWindowValidation.ts b/server/src/validation/maintenanceWindowValidation.ts index 5d83b3fed..9bf775596 100644 --- a/server/src/validation/maintenanceWindowValidation.ts +++ b/server/src/validation/maintenanceWindowValidation.ts @@ -19,13 +19,23 @@ export const createMaintenanceWindowBodyValidation = z repeat: z.number().min(0, "Repeat must be a non-negative number"), }) .strict() - .refine((data) => new Date(data.end).getTime() > new Date(data.start).getTime(), { - message: "End must be after start", - path: ["end"], - }) - .refine((data) => data.repeat > 0 || new Date(data.end).getTime() > Date.now(), { - message: "End must be in the future for one-time maintenance windows", - path: ["end"], + .superRefine((data, ctx) => { + const start = new Date(data.start).getTime(); + const end = new Date(data.end).getTime(); + if (end <= start) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "End must be after start", + path: ["end"], + }); + } + if (data.repeat === 0 && end <= Date.now()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "End must be in the future for one-time maintenance windows", + path: ["end"], + }); + } }); export const getMaintenanceWindowByIdParamValidation = z.object({ @@ -64,7 +74,16 @@ export const editMaintenanceByIdWindowBodyValidation = z durationUnit: z.enum(["seconds", "minutes", "hours", "days"]).optional(), }) .strict() - .refine((data) => !data.start || !data.end || new Date(data.end).getTime() > new Date(data.start).getTime(), { - message: "End must be after start", - path: ["end"], + .superRefine((data, ctx) => { + if (data.start && data.end) { + const start = new Date(data.start).getTime(); + const end = new Date(data.end).getTime(); + if (end <= start) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "End must be after start", + path: ["end"], + }); + } + } });