From 64ad7641af7623d1a1f5633802bef57494c23542 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 4 May 2026 11:35:07 -0700 Subject: [PATCH] Add migration Fixes #2968 Fixes #2990 --- server/setup/migrationsPg.ts | 4 +- server/setup/migrationsSqlite.ts | 4 +- server/setup/scriptsPg/1.18.3.ts | 173 +++++++++++++++++++++++++++ server/setup/scriptsSqlite/1.18.3.ts | 172 ++++++++++++++++++++++++++ 4 files changed, 351 insertions(+), 2 deletions(-) create mode 100644 server/setup/scriptsPg/1.18.3.ts create mode 100644 server/setup/scriptsSqlite/1.18.3.ts diff --git a/server/setup/migrationsPg.ts b/server/setup/migrationsPg.ts index 992cc2583..7c7c67ad7 100644 --- a/server/setup/migrationsPg.ts +++ b/server/setup/migrationsPg.ts @@ -23,6 +23,7 @@ import m14 from "./scriptsPg/1.15.4"; import m15 from "./scriptsPg/1.16.0"; import m16 from "./scriptsPg/1.17.0"; import m17 from "./scriptsPg/1.18.0"; +import m18 from "./scriptsPg/1.18.3"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -45,7 +46,8 @@ const migrations = [ { version: "1.15.4", run: m14 }, { version: "1.16.0", run: m15 }, { version: "1.17.0", run: m16 }, - { version: "1.18.0", run: m17 } + { version: "1.18.0", run: m17 }, + { version: "1.18.3", run: m18 } // Add new migrations here as they are created ] as { version: string; diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index c32437aec..43ce07629 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -41,6 +41,7 @@ import m35 from "./scriptsSqlite/1.15.4"; import m36 from "./scriptsSqlite/1.16.0"; import m37 from "./scriptsSqlite/1.17.0"; import m38 from "./scriptsSqlite/1.18.0"; +import m39 from "./scriptsSqlite/1.18.3"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -79,7 +80,8 @@ const migrations = [ { version: "1.15.4", run: m35 }, { version: "1.16.0", run: m36 }, { version: "1.17.0", run: m37 }, - { version: "1.18.0", run: m38 } + { version: "1.18.0", run: m38 }, + { version: "1.18.3", run: m39 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scriptsPg/1.18.3.ts b/server/setup/scriptsPg/1.18.3.ts new file mode 100644 index 000000000..301ed820c --- /dev/null +++ b/server/setup/scriptsPg/1.18.3.ts @@ -0,0 +1,173 @@ +import { db } from "@server/db/pg/driver"; +import { sql } from "drizzle-orm"; + +const version = "1.18.3"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + // Query existing targetHealthCheck data with joined siteId and orgId before + // the transaction adds the new columns (which start NULL for existing rows). + // We will delete all rows and reinsert them with targetHealthCheckId = targetId + // so the two IDs form a stable 1:1 mapping. + const healthChecksQuery = await db.execute( + sql`SELECT + thc."targetHealthCheckId", + thc."targetId", + t."siteId", + s."orgId", + r."name" AS "resourceName", + t."ip", + t."port" + FROM "targetHealthCheck" thc + JOIN "targets" t ON thc."targetId" = t."targetId" + JOIN "sites" s ON t."siteId" = s."siteId" + JOIN "resources" r ON t."resourceId" = r."resourceId" + WHERE thc."name" IS NULL OR thc."name" = ''` + ); + + const existingHealthChecks = healthChecksQuery.rows as { + targetHealthCheckId: number; + targetId: number; + siteId: number; + orgId: string; + resourceName: string; + ip: string; + port: number; + }[]; + + console.log( + `Found ${existingHealthChecks.length} existing targetHealthCheck row(s) to migrate` + ); + + try { + await db.execute(sql`BEGIN`); + + await db.execute(sql` + CREATE TABLE "trialNotifications" ( + "notificationId" serial PRIMARY KEY NOT NULL, + "subscriptionId" varchar(255) NOT NULL, + "notificationType" varchar(50) NOT NULL, + "sentAt" bigint NOT NULL + ); + `); + + await db.execute(sql` + ALTER TABLE "trialNotifications" ADD CONSTRAINT "trialNotifications_subscriptionId_subscriptions_subscriptionId_fk" FOREIGN KEY ("subscriptionId") REFERENCES "public"."subscriptions"("subscriptionId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql`COMMIT`); + console.log("Migrated database"); + } catch (e) { + await db.execute(sql`ROLLBACK`); + console.log("Unable to migrate database"); + console.log(e); + throw e; + } + + if (existingHealthChecks.length > 0) { + // fix the name column + try { + for (const hc of existingHealthChecks) { + await db.execute(sql` + UPDATE "targetHealthCheck" + SET "name" = ${`Resource ${hc.resourceName} - ${hc.ip}:${hc.port}`} + WHERE "targetHealthCheckId" = ${hc.targetHealthCheckId} + `); + } + + console.log( + `Migrated ${existingHealthChecks.length} targetHealthCheck row(s) with corrected IDs` + ); + } catch (e) { + console.error("Error while migrating targetHealthCheck rows:", e); + throw e; + } + } + + // Recompute resource health by aggregating across the resource's targets' + // target health checks, then update the resources.health column to match. + try { + const resourceTargetHealthQuery = await db.execute( + sql`SELECT + r."resourceId" AS "resourceId", + r."orgId" AS "orgId", + r."health" AS "currentHealth", + thc."hcHealth" AS "hcHealth" + FROM "resources" r + LEFT JOIN "targets" t ON t."resourceId" = r."resourceId" + LEFT JOIN "targetHealthCheck" thc ON thc."targetId" = t."targetId"` + ); + const resourceTargetHealthRows = resourceTargetHealthQuery.rows as { + resourceId: number; + orgId: string; + currentHealth: string | null; + hcHealth: string | null; + }[]; + + const resourceHealthMap = new Map< + number, + { + hasHealthy: boolean; + hasUnhealthy: boolean; + hasUnknown: boolean; + orgId: string; + currentHealth: string | null; + } + >(); + for (const row of resourceTargetHealthRows) { + const entry = resourceHealthMap.get(row.resourceId) ?? { + hasHealthy: false, + hasUnhealthy: false, + hasUnknown: false, + orgId: row.orgId, + currentHealth: row.currentHealth + }; + const status = row.hcHealth ?? "unknown"; + if (status === "healthy") entry.hasHealthy = true; + else if (status === "unhealthy") entry.hasUnhealthy = true; + else entry.hasUnknown = true; + resourceHealthMap.set(row.resourceId, entry); + } + + const now = Math.floor(Date.now() / 1000); + let updatedResourceCount = 0; + for (const [resourceId, entry] of resourceHealthMap.entries()) { + let aggregated: "healthy" | "unhealthy" | "degraded" | "unknown"; + if (entry.hasHealthy && entry.hasUnhealthy) { + aggregated = "degraded"; + } else if (entry.hasHealthy) { + aggregated = "healthy"; + } else if (entry.hasUnhealthy) { + aggregated = "unhealthy"; + } else { + aggregated = "unknown"; + } + + if (entry.currentHealth !== aggregated) { + await db.execute(sql` + UPDATE "resources" + SET "health" = ${aggregated} + WHERE "resourceId" = ${resourceId} + `); + await db.execute(sql` + INSERT INTO "statusHistory" ("entityType", "entityId", "orgId", "status", "timestamp") + VALUES ('resource', ${resourceId}, ${entry.orgId}, ${aggregated}, ${now}) + `); + updatedResourceCount++; + } + } + + console.log( + `Recomputed health for ${updatedResourceCount} resource(s) based on target health checks` + ); + } catch (e) { + console.error( + "Error while recomputing resource health from target health checks:", + e + ); + throw e; + } + + console.log(`${version} migration complete`); +} diff --git a/server/setup/scriptsSqlite/1.18.3.ts b/server/setup/scriptsSqlite/1.18.3.ts new file mode 100644 index 000000000..ad82327fe --- /dev/null +++ b/server/setup/scriptsSqlite/1.18.3.ts @@ -0,0 +1,172 @@ +import { APP_PATH } from "@server/lib/consts"; +import Database from "better-sqlite3"; +import path from "path"; + +const version = "1.18.3"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + const location = path.join(APP_PATH, "db", "db.sqlite"); + const db = new Database(location); + + try { + db.pragma("foreign_keys = OFF"); + + db.transaction(() => { + db.prepare( + ` + CREATE TABLE 'trialNotifications' ( + 'notificationId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'subscriptionId' text NOT NULL, + 'notificationType' text NOT NULL, + 'sentAt' integer NOT NULL, + FOREIGN KEY ('subscriptionId') REFERENCES 'subscriptions'('subscriptionId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + })(); + + db.pragma("foreign_keys = ON"); + + console.log("Migrated database"); + + // Fix names for health checks that don't have one + const healthChecksWithoutName = db + .prepare( + `SELECT + thc."targetHealthCheckId", + r."name" AS "resourceName", + t."ip", + t."port" + FROM 'targetHealthCheck' thc + JOIN 'targets' t ON thc."targetId" = t."targetId" + JOIN 'resources' r ON t."resourceId" = r."resourceId" + WHERE thc."name" IS NULL OR thc."name" = ''` + ) + .all() as { + targetHealthCheckId: number; + resourceName: string; + ip: string; + port: number; + }[]; + + console.log( + `Found ${healthChecksWithoutName.length} targetHealthCheck row(s) with missing names` + ); + + if (healthChecksWithoutName.length > 0) { + const updateName = db.prepare( + `UPDATE 'targetHealthCheck' SET "name" = ? WHERE "targetHealthCheckId" = ?` + ); + const updateAllNames = db.transaction(() => { + for (const hc of healthChecksWithoutName) { + updateName.run( + `Resource ${hc.resourceName} - ${hc.ip}:${hc.port}`, + hc.targetHealthCheckId + ); + } + }); + updateAllNames(); + console.log( + `Updated names for ${healthChecksWithoutName.length} targetHealthCheck row(s)` + ); + } + + // Recompute resource health by aggregating across the resource's + // targets' target health checks, then update resources.health and + // insert a statusHistory entry for any resource whose health changed. + const resourceTargetHealthRows = db + .prepare( + `SELECT + r."resourceId" AS "resourceId", + r."orgId" AS "orgId", + r."health" AS "currentHealth", + thc."hcHealth" AS "hcHealth" + FROM 'resources' r + LEFT JOIN 'targets' t ON t."resourceId" = r."resourceId" + LEFT JOIN 'targetHealthCheck' thc ON thc."targetId" = t."targetId"` + ) + .all() as { + resourceId: number; + orgId: string; + currentHealth: string | null; + hcHealth: string | null; + }[]; + + const resourceHealthMap = new Map< + number, + { + hasHealthy: boolean; + hasUnhealthy: boolean; + hasUnknown: boolean; + orgId: string; + currentHealth: string | null; + } + >(); + for (const row of resourceTargetHealthRows) { + const entry = resourceHealthMap.get(row.resourceId) ?? { + hasHealthy: false, + hasUnhealthy: false, + hasUnknown: false, + orgId: row.orgId, + currentHealth: row.currentHealth + }; + const status = row.hcHealth ?? "unknown"; + if (status === "healthy") entry.hasHealthy = true; + else if (status === "unhealthy") entry.hasUnhealthy = true; + else entry.hasUnknown = true; + resourceHealthMap.set(row.resourceId, entry); + } + + const updateResourceHealth = db.prepare( + `UPDATE 'resources' SET "health" = ? WHERE "resourceId" = ?` + ); + const insertResourceHistory = db.prepare( + `INSERT INTO 'statusHistory' ("entityType", "entityId", "orgId", "status", "timestamp") VALUES (?, ?, ?, ?, ?)` + ); + + const now = Math.floor(Date.now() / 1000); + let updatedResourceCount = 0; + + const recomputeAll = db.transaction(() => { + for (const [resourceId, entry] of resourceHealthMap.entries()) { + let aggregated: + | "healthy" + | "unhealthy" + | "degraded" + | "unknown"; + if (entry.hasHealthy && entry.hasUnhealthy) { + aggregated = "degraded"; + } else if (entry.hasHealthy) { + aggregated = "healthy"; + } else if (entry.hasUnhealthy) { + aggregated = "unhealthy"; + } else { + aggregated = "unknown"; + } + + if (entry.currentHealth !== aggregated) { + updateResourceHealth.run(aggregated, resourceId); + insertResourceHistory.run( + "resource", + resourceId, + entry.orgId, + aggregated, + now + ); + updatedResourceCount++; + } + } + }); + recomputeAll(); + console.log( + `Recomputed health for ${updatedResourceCount} resource(s) based on target health checks` + ); + } catch (e) { + console.log("Failed to migrate db:", e); + throw e; + } + + console.log(`${version} migration complete`); +}