diff --git a/server/lib/consts.ts b/server/lib/consts.ts index 8ad4f48e9..d2218e874 100644 --- a/server/lib/consts.ts +++ b/server/lib/consts.ts @@ -2,7 +2,7 @@ import path from "path"; import { fileURLToPath } from "url"; // This is a placeholder value replaced by the build process -export const APP_VERSION = "1.17.0"; +export const APP_VERSION = "1.18.0"; export const __FILENAME = fileURLToPath(import.meta.url); export const __DIRNAME = path.dirname(__FILENAME); diff --git a/server/setup/migrationsPg.ts b/server/setup/migrationsPg.ts index 9ba0b9767..992cc2583 100644 --- a/server/setup/migrationsPg.ts +++ b/server/setup/migrationsPg.ts @@ -22,6 +22,7 @@ import m13 from "./scriptsPg/1.15.3"; 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"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -43,7 +44,8 @@ const migrations = [ { version: "1.15.3", run: m13 }, { version: "1.15.4", run: m14 }, { version: "1.16.0", run: m15 }, - { version: "1.17.0", run: m16 } + { version: "1.17.0", run: m16 }, + { version: "1.18.0", run: m17 } // Add new migrations here as they are created ] as { version: string; diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index 45a29ec29..c32437aec 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -40,6 +40,7 @@ import m34 from "./scriptsSqlite/1.15.3"; 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"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -77,7 +78,8 @@ const migrations = [ { version: "1.15.3", run: m34 }, { version: "1.15.4", run: m35 }, { version: "1.16.0", run: m36 }, - { version: "1.17.0", run: m37 } + { version: "1.17.0", run: m37 }, + { version: "1.18.0", run: m38 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scriptsPg/1.18.0.ts b/server/setup/scriptsPg/1.18.0.ts new file mode 100644 index 000000000..9c0b7568b --- /dev/null +++ b/server/setup/scriptsPg/1.18.0.ts @@ -0,0 +1,434 @@ +import { db } from "@server/db/pg/driver"; +import { sql } from "drizzle-orm"; + +const version = "1.18.0"; + +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", + thc."hcEnabled", + thc."hcPath", + thc."hcScheme", + thc."hcMode", + thc."hcHostname", + thc."hcPort", + thc."hcInterval", + thc."hcUnhealthyInterval", + thc."hcTimeout", + thc."hcHeaders", + thc."hcFollowRedirects", + thc."hcMethod", + thc."hcStatus", + thc."hcHealth", + thc."hcTlsServerName" + FROM "targetHealthCheck" thc + JOIN "targets" t ON thc."targetId" = t."targetId" + JOIN "sites" s ON t."siteId" = s."siteId"` + ); + const existingHealthChecks = healthChecksQuery.rows as { + targetHealthCheckId: number; + targetId: number; + siteId: number; + orgId: string; + hcEnabled: boolean; + hcPath: string | null; + hcScheme: string | null; + hcMode: string | null; + hcHostname: string | null; + hcPort: number | null; + hcInterval: number | null; + hcUnhealthyInterval: number | null; + hcTimeout: number | null; + hcHeaders: string | null; + hcFollowRedirects: boolean | null; + hcMethod: string | null; + hcStatus: number | null; + hcHealth: string | null; + hcTlsServerName: string | null; + }[]; + + console.log( + `Found ${existingHealthChecks.length} existing targetHealthCheck row(s) to migrate` + ); + + try { + await db.execute(sql`BEGIN`); + + await db.execute(sql` + CREATE TABLE "alertEmailActions" ( + "emailActionId" serial PRIMARY KEY NOT NULL, + "alertRuleId" integer NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "lastSentAt" bigint + ); + `); + + await db.execute(sql` + CREATE TABLE "alertEmailRecipients" ( + "recipientId" serial PRIMARY KEY NOT NULL, + "emailActionId" integer NOT NULL, + "userId" varchar, + "roleId" integer, + "email" varchar(255) + ); + `); + + await db.execute(sql` + CREATE TABLE "alertHealthChecks" ( + "alertRuleId" integer NOT NULL, + "healthCheckId" integer NOT NULL + ); + `); + + await db.execute(sql` + CREATE TABLE "alertResources" ( + "alertRuleId" integer NOT NULL, + "resourceId" integer NOT NULL + ); + `); + + await db.execute(sql` + CREATE TABLE "alertRules" ( + "alertRuleId" serial PRIMARY KEY NOT NULL, + "orgId" varchar(255) NOT NULL, + "name" varchar(255) NOT NULL, + "eventType" varchar(100) NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "cooldownSeconds" integer DEFAULT 300 NOT NULL, + "allSites" boolean DEFAULT false NOT NULL, + "allHealthChecks" boolean DEFAULT false NOT NULL, + "allResources" boolean DEFAULT false NOT NULL, + "lastTriggeredAt" bigint, + "createdAt" bigint NOT NULL, + "updatedAt" bigint NOT NULL + ); + `); + + await db.execute(sql` + CREATE TABLE "alertSites" ( + "alertRuleId" integer NOT NULL, + "siteId" integer NOT NULL + ); + `); + + await db.execute(sql` + CREATE TABLE "alertWebhookActions" ( + "webhookActionId" serial PRIMARY KEY NOT NULL, + "alertRuleId" integer NOT NULL, + "webhookUrl" text NOT NULL, + "config" text, + "enabled" boolean DEFAULT true NOT NULL, + "lastSentAt" bigint + ); + `); + + await db.execute(sql` + CREATE TABLE "networks" ( + "networkId" serial PRIMARY KEY NOT NULL, + "niceId" text, + "name" text, + "scope" varchar DEFAULT 'global' NOT NULL, + "orgId" varchar NOT NULL + ); + `); + + await db.execute(sql` + CREATE TABLE "siteNetworks" ( + "siteId" integer NOT NULL, + "networkId" integer NOT NULL + ); + `); + + await db.execute(sql` + CREATE TABLE "statusHistory" ( + "id" serial PRIMARY KEY NOT NULL, + "entityType" varchar NOT NULL, + "entityId" integer NOT NULL, + "orgId" varchar NOT NULL, + "status" varchar NOT NULL, + "timestamp" integer NOT NULL + ); + `); + + await db.execute(sql` + ALTER TABLE "siteResources" DROP CONSTRAINT "siteResources_siteId_sites_siteId_fk"; + `); + + await db.execute(sql` + ALTER TABLE "targetHealthCheck" ALTER COLUMN "targetId" DROP NOT NULL; + `); + + await db.execute(sql` + ALTER TABLE "subscriptions" ADD COLUMN "expiresAt" bigint; + `); + + await db.execute(sql` + ALTER TABLE "subscriptions" ADD COLUMN "trial" boolean DEFAULT false; + `); + + await db.execute(sql` + ALTER TABLE "requestAuditLog" ADD COLUMN "siteResourceId" integer; + `); + + await db.execute(sql` + ALTER TABLE "siteResources" ADD COLUMN "networkId" integer; + `); + + await db.execute(sql` + ALTER TABLE "siteResources" ADD COLUMN "defaultNetworkId" integer; + `); + + await db.execute(sql` + ALTER TABLE "siteResources" ADD COLUMN "ssl" boolean DEFAULT false NOT NULL; + `); + + await db.execute(sql` + ALTER TABLE "siteResources" ADD COLUMN "scheme" varchar; + `); + + await db.execute(sql` + ALTER TABLE "siteResources" ADD COLUMN "domainId" varchar; + `); + + await db.execute(sql` + ALTER TABLE "siteResources" ADD COLUMN "subdomain" varchar; + `); + + await db.execute(sql` + ALTER TABLE "siteResources" ADD COLUMN "fullDomain" varchar; + `); + + // Add orgId and siteId as nullable first; NOT NULL constraints are applied + // after the data migration below once every row has been populated. + await db.execute(sql` + ALTER TABLE "targetHealthCheck" ADD COLUMN "orgId" varchar; + `); + + await db.execute(sql` + ALTER TABLE "targetHealthCheck" ADD COLUMN "siteId" integer; + `); + + await db.execute(sql` + ALTER TABLE "targetHealthCheck" ADD COLUMN "name" varchar; + `); + + await db.execute(sql` + ALTER TABLE "targetHealthCheck" ADD COLUMN "hcHealthyThreshold" integer DEFAULT 1; + `); + + await db.execute(sql` + ALTER TABLE "targetHealthCheck" ADD COLUMN "hcUnhealthyThreshold" integer DEFAULT 1; + `); + + await db.execute(sql` + ALTER TABLE "alertEmailActions" ADD CONSTRAINT "alertEmailActions_alertRuleId_alertRules_alertRuleId_fk" FOREIGN KEY ("alertRuleId") REFERENCES "public"."alertRules"("alertRuleId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "alertEmailRecipients" ADD CONSTRAINT "alertEmailRecipients_emailActionId_alertEmailActions_emailActionId_fk" FOREIGN KEY ("emailActionId") REFERENCES "public"."alertEmailActions"("emailActionId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "alertEmailRecipients" ADD CONSTRAINT "alertEmailRecipients_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "alertEmailRecipients" ADD CONSTRAINT "alertEmailRecipients_roleId_roles_roleId_fk" FOREIGN KEY ("roleId") REFERENCES "public"."roles"("roleId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "alertHealthChecks" ADD CONSTRAINT "alertHealthChecks_alertRuleId_alertRules_alertRuleId_fk" FOREIGN KEY ("alertRuleId") REFERENCES "public"."alertRules"("alertRuleId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "alertHealthChecks" ADD CONSTRAINT "alertHealthChecks_healthCheckId_targetHealthCheck_targetHealthCheckId_fk" FOREIGN KEY ("healthCheckId") REFERENCES "public"."targetHealthCheck"("targetHealthCheckId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "alertResources" ADD CONSTRAINT "alertResources_alertRuleId_alertRules_alertRuleId_fk" FOREIGN KEY ("alertRuleId") REFERENCES "public"."alertRules"("alertRuleId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "alertResources" ADD CONSTRAINT "alertResources_resourceId_resources_resourceId_fk" FOREIGN KEY ("resourceId") REFERENCES "public"."resources"("resourceId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "alertRules" ADD CONSTRAINT "alertRules_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "alertSites" ADD CONSTRAINT "alertSites_alertRuleId_alertRules_alertRuleId_fk" FOREIGN KEY ("alertRuleId") REFERENCES "public"."alertRules"("alertRuleId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "alertSites" ADD CONSTRAINT "alertSites_siteId_sites_siteId_fk" FOREIGN KEY ("siteId") REFERENCES "public"."sites"("siteId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "alertWebhookActions" ADD CONSTRAINT "alertWebhookActions_alertRuleId_alertRules_alertRuleId_fk" FOREIGN KEY ("alertRuleId") REFERENCES "public"."alertRules"("alertRuleId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "networks" ADD CONSTRAINT "networks_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "siteNetworks" ADD CONSTRAINT "siteNetworks_siteId_sites_siteId_fk" FOREIGN KEY ("siteId") REFERENCES "public"."sites"("siteId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "siteNetworks" ADD CONSTRAINT "siteNetworks_networkId_networks_networkId_fk" FOREIGN KEY ("networkId") REFERENCES "public"."networks"("networkId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "statusHistory" ADD CONSTRAINT "statusHistory_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + CREATE INDEX "idx_statusHistory_entity" ON "statusHistory" USING btree ("entityType","entityId","timestamp"); + `); + + await db.execute(sql` + CREATE INDEX "idx_statusHistory_org_timestamp" ON "statusHistory" USING btree ("orgId","timestamp"); + `); + + await db.execute(sql` + ALTER TABLE "siteResources" ADD CONSTRAINT "siteResources_networkId_networks_networkId_fk" FOREIGN KEY ("networkId") REFERENCES "public"."networks"("networkId") ON DELETE set null ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "siteResources" ADD CONSTRAINT "siteResources_defaultNetworkId_networks_networkId_fk" FOREIGN KEY ("defaultNetworkId") REFERENCES "public"."networks"("networkId") ON DELETE restrict ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "siteResources" ADD CONSTRAINT "siteResources_domainId_domains_domainId_fk" FOREIGN KEY ("domainId") REFERENCES "public"."domains"("domainId") ON DELETE set null ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "targetHealthCheck" ADD CONSTRAINT "targetHealthCheck_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "targetHealthCheck" ADD CONSTRAINT "targetHealthCheck_siteId_sites_siteId_fk" FOREIGN KEY ("siteId") REFERENCES "public"."sites"("siteId") ON DELETE cascade ON UPDATE no action; + `); + + await db.execute(sql` + ALTER TABLE "siteResources" DROP COLUMN "siteId"; + `); + + await db.execute(sql` + ALTER TABLE "siteResources" DROP COLUMN "protocol"; + `); + + 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; + } + + // Reinsert targetHealthCheck rows with corrected IDs: + // targetHealthCheckId is set to the same integer as targetId (1:1 mapping), + // siteId and orgId are populated from the associated target and site. + // + // Because targetHealthCheckId is a serial (sequence-backed) column, inserting + // explicit values is allowed in PostgreSQL — the sequence is simply bypassed. + // After all inserts we advance the sequence to MAX(targetHealthCheckId) via + // setval() so future auto-inserts never collide with the explicit IDs we used. + if (existingHealthChecks.length > 0) { + try { + // Remove all existing rows first. The alertHealthChecks table is brand + // new in this migration so there are no FK references to worry about. + await db.execute(sql`DELETE FROM "targetHealthCheck"`); + + for (const hc of existingHealthChecks) { + await db.execute(sql` + INSERT INTO "targetHealthCheck" ( + "targetHealthCheckId", + "targetId", + "orgId", + "siteId", + "hcEnabled", + "hcPath", + "hcScheme", + "hcMode", + "hcHostname", + "hcPort", + "hcInterval", + "hcUnhealthyInterval", + "hcTimeout", + "hcHeaders", + "hcFollowRedirects", + "hcMethod", + "hcStatus", + "hcHealth", + "hcTlsServerName" + ) VALUES ( + ${hc.targetId}, + ${hc.targetId}, + ${hc.orgId}, + ${hc.siteId}, + ${hc.hcEnabled}, + ${hc.hcPath}, + ${hc.hcScheme}, + ${hc.hcMode}, + ${hc.hcHostname}, + ${hc.hcPort}, + ${hc.hcInterval}, + ${hc.hcUnhealthyInterval}, + ${hc.hcTimeout}, + ${hc.hcHeaders}, + ${hc.hcFollowRedirects}, + ${hc.hcMethod}, + ${hc.hcStatus}, + ${hc.hcHealth}, + ${hc.hcTlsServerName} + ) + `); + } + + // Now that every row has orgId and siteId populated, enforce NOT NULL. + await db.execute( + sql`ALTER TABLE "targetHealthCheck" ALTER COLUMN "orgId" SET NOT NULL` + ); + await db.execute( + sql`ALTER TABLE "targetHealthCheck" ALTER COLUMN "siteId" SET NOT NULL` + ); + + // Advance the sequence so the next auto-insert picks up after the + // largest ID we explicitly wrote. setval(..., max, true) means the + // next nextval() call will return max + 1. + await db.execute(sql` + SELECT setval( + pg_get_serial_sequence('"targetHealthCheck"', 'targetHealthCheckId'), + (SELECT MAX("targetHealthCheckId") FROM "targetHealthCheck"), + true + ) + `); + + console.log( + `Migrated ${existingHealthChecks.length} targetHealthCheck row(s) with corrected IDs` + ); + } catch (e) { + console.error( + "Error while migrating targetHealthCheck rows:", + e + ); + throw e; + } + } + + console.log(`${version} migration complete`); +} diff --git a/server/setup/scriptsSqlite/1.18.0.ts b/server/setup/scriptsSqlite/1.18.0.ts new file mode 100644 index 000000000..edb7f9c23 --- /dev/null +++ b/server/setup/scriptsSqlite/1.18.0.ts @@ -0,0 +1,403 @@ +import { APP_PATH } from "@server/lib/consts"; +import Database from "better-sqlite3"; +import path from "path"; + +const version = "1.18.0"; + +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"); + + // Query existing targetHealthCheck data with joined siteId and orgId before + // the transaction drops and recreates the table + const existingHealthChecks = db + .prepare( + `SELECT + thc."targetHealthCheckId", + thc."targetId", + t."siteId", + s."orgId", + thc."hcEnabled", + thc."hcPath", + thc."hcScheme", + thc."hcMode", + thc."hcHostname", + thc."hcPort", + thc."hcInterval", + thc."hcUnhealthyInterval", + thc."hcTimeout", + thc."hcHeaders", + thc."hcFollowRedirects", + thc."hcMethod", + thc."hcStatus", + thc."hcHealth", + thc."hcTlsServerName" + FROM 'targetHealthCheck' thc + JOIN 'targets' t ON thc."targetId" = t."targetId" + JOIN 'sites' s ON t."siteId" = s."siteId"` + ) + .all() as { + targetHealthCheckId: number; + targetId: number; + siteId: number; + orgId: string; + hcEnabled: number; + hcPath: string | null; + hcScheme: string | null; + hcMode: string | null; + hcHostname: string | null; + hcPort: number | null; + hcInterval: number | null; + hcUnhealthyInterval: number | null; + hcTimeout: number | null; + hcHeaders: string | null; + hcFollowRedirects: number | null; + hcMethod: string | null; + hcStatus: number | null; + hcHealth: string | null; + hcTlsServerName: string | null; + }[]; + + console.log( + `Found ${existingHealthChecks.length} existing targetHealthCheck row(s) to migrate` + ); + + db.transaction(() => { + db.prepare( + ` + CREATE TABLE 'alertEmailActions' ( + 'emailActionId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'alertRuleId' integer NOT NULL, + 'enabled' integer DEFAULT true NOT NULL, + 'lastSentAt' integer, + FOREIGN KEY ('alertRuleId') REFERENCES 'alertRules'('alertRuleId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + db.prepare( + ` + CREATE TABLE 'alertEmailRecipients' ( + 'recipientId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'emailActionId' integer NOT NULL, + 'userId' text, + 'roleId' integer, + 'email' text, + FOREIGN KEY ('emailActionId') REFERENCES 'alertEmailActions'('emailActionId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('userId') REFERENCES 'user'('id') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('roleId') REFERENCES 'roles'('roleId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + db.prepare( + ` + CREATE TABLE 'alertHealthChecks' ( + 'alertRuleId' integer NOT NULL, + 'healthCheckId' integer NOT NULL, + FOREIGN KEY ('alertRuleId') REFERENCES 'alertRules'('alertRuleId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('healthCheckId') REFERENCES 'targetHealthCheck'('targetHealthCheckId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + db.prepare( + ` + CREATE TABLE 'alertResources' ( + 'alertRuleId' integer NOT NULL, + 'resourceId' integer NOT NULL, + FOREIGN KEY ('alertRuleId') REFERENCES 'alertRules'('alertRuleId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('resourceId') REFERENCES 'resources'('resourceId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + db.prepare( + ` + CREATE TABLE 'alertRules' ( + 'alertRuleId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'orgId' text NOT NULL, + 'name' text NOT NULL, + 'eventType' text NOT NULL, + 'enabled' integer DEFAULT true NOT NULL, + 'cooldownSeconds' integer DEFAULT 300 NOT NULL, + 'allSites' integer DEFAULT false NOT NULL, + 'allHealthChecks' integer DEFAULT false NOT NULL, + 'allResources' integer DEFAULT false NOT NULL, + 'lastTriggeredAt' integer, + 'createdAt' integer NOT NULL, + 'updatedAt' integer NOT NULL, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + db.prepare( + ` + CREATE TABLE 'alertSites' ( + 'alertRuleId' integer NOT NULL, + 'siteId' integer NOT NULL, + FOREIGN KEY ('alertRuleId') REFERENCES 'alertRules'('alertRuleId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('siteId') REFERENCES 'sites'('siteId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + db.prepare( + ` + CREATE TABLE 'alertWebhookActions' ( + 'webhookActionId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'alertRuleId' integer NOT NULL, + 'webhookUrl' text NOT NULL, + 'config' text, + 'enabled' integer DEFAULT true NOT NULL, + 'lastSentAt' integer, + FOREIGN KEY ('alertRuleId') REFERENCES 'alertRules'('alertRuleId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + db.prepare( + ` + CREATE TABLE 'networks' ( + 'networkId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'niceId' text, + 'name' text, + 'scope' text DEFAULT 'global' NOT NULL, + 'orgId' text NOT NULL, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + db.prepare( + ` + CREATE TABLE 'siteNetworks' ( + 'siteId' integer NOT NULL, + 'networkId' integer NOT NULL, + FOREIGN KEY ('siteId') REFERENCES 'sites'('siteId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('networkId') REFERENCES 'networks'('networkId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + db.prepare( + ` + CREATE TABLE 'statusHistory' ( + 'id' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'entityType' text NOT NULL, + 'entityId' integer NOT NULL, + 'orgId' text NOT NULL, + 'status' text NOT NULL, + 'timestamp' integer NOT NULL, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + db.prepare( + ` + CREATE INDEX 'idx_statusHistory_entity' ON 'statusHistory' ('entityType','entityId','timestamp'); + ` + ).run(); + db.prepare( + ` + CREATE INDEX 'idx_statusHistory_org_timestamp' ON 'statusHistory' ('orgId','timestamp'); + ` + ).run(); + + db.prepare( + ` + CREATE TABLE '__new_siteResources' ( + 'siteResourceId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'orgId' text NOT NULL, + 'networkId' integer, + 'defaultNetworkId' integer, + 'niceId' text NOT NULL, + 'name' text NOT NULL, + 'ssl' integer DEFAULT false NOT NULL, + 'mode' text NOT NULL, + 'scheme' text, + 'proxyPort' integer, + 'destinationPort' integer, + 'destination' text NOT NULL, + 'enabled' integer DEFAULT true NOT NULL, + 'alias' text, + 'aliasAddress' text, + 'tcpPortRangeString' text DEFAULT '*' NOT NULL, + 'udpPortRangeString' text DEFAULT '*' NOT NULL, + 'disableIcmp' integer DEFAULT false NOT NULL, + 'authDaemonPort' integer DEFAULT 22123, + 'authDaemonMode' text DEFAULT 'site', + 'domainId' text, + 'subdomain' text, + 'fullDomain' text, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('networkId') REFERENCES 'networks'('networkId') ON UPDATE no action ON DELETE set null, + FOREIGN KEY ('defaultNetworkId') REFERENCES 'networks'('networkId') ON UPDATE no action ON DELETE restrict, + FOREIGN KEY ('domainId') REFERENCES 'domains'('domainId') ON UPDATE no action ON DELETE set null + ); + ` + ).run(); + db.prepare( + ` + INSERT INTO '__new_siteResources'("siteResourceId", "orgId", "networkId", "defaultNetworkId", "niceId", "name", "ssl", "mode", "scheme", "proxyPort", "destinationPort", "destination", "enabled", "alias", "aliasAddress", "tcpPortRangeString", "udpPortRangeString", "disableIcmp", "authDaemonPort", "authDaemonMode", "domainId", "subdomain", "fullDomain") SELECT "siteResourceId", "orgId", "networkId", "defaultNetworkId", "niceId", "name", "ssl", "mode", "scheme", "proxyPort", "destinationPort", "destination", "enabled", "alias", "aliasAddress", "tcpPortRangeString", "udpPortRangeString", "disableIcmp", "authDaemonPort", "authDaemonMode", "domainId", "subdomain", "fullDomain" FROM 'siteResources'; + ` + ).run(); + db.prepare( + ` + DROP TABLE 'siteResources'; + ` + ).run(); + db.prepare( + ` + ALTER TABLE '__new_siteResources' RENAME TO 'siteResources'; + ` + ).run(); + db.prepare( + ` + CREATE TABLE '__new_targetHealthCheck' ( + 'targetHealthCheckId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'targetId' integer, + 'orgId' text NOT NULL, + 'siteId' integer NOT NULL, + 'name' text, + 'hcEnabled' integer DEFAULT false NOT NULL, + 'hcPath' text, + 'hcScheme' text, + 'hcMode' text DEFAULT 'http', + 'hcHostname' text, + 'hcPort' integer, + 'hcInterval' integer DEFAULT 30, + 'hcUnhealthyInterval' integer DEFAULT 30, + 'hcTimeout' integer DEFAULT 5, + 'hcHeaders' text, + 'hcFollowRedirects' integer DEFAULT true, + 'hcMethod' text DEFAULT 'GET', + 'hcStatus' integer, + 'hcHealth' text DEFAULT 'unknown', + 'hcTlsServerName' text, + 'hcHealthyThreshold' integer DEFAULT 1, + 'hcUnhealthyThreshold' integer DEFAULT 1, + FOREIGN KEY ('targetId') REFERENCES 'targets'('targetId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('siteId') REFERENCES 'sites'('siteId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + // INSERT INTO '__new_targetHealthCheck'("targetHealthCheckId", "targetId", "orgId", "siteId", "name", "hcEnabled", "hcPath", "hcScheme", "hcMode", "hcHostname", "hcPort", "hcInterval", "hcUnhealthyInterval", "hcTimeout", "hcHeaders", "hcFollowRedirects", "hcMethod", "hcStatus", "hcHealth", "hcTlsServerName", "hcHealthyThreshold", "hcUnhealthyThreshold") SELECT "targetHealthCheckId", "targetId", "orgId", "siteId", "name", "hcEnabled", "hcPath", "hcScheme", "hcMode", "hcHostname", "hcPort", "hcInterval", "hcUnhealthyInterval", "hcTimeout", "hcHeaders", "hcFollowRedirects", "hcMethod", "hcStatus", "hcHealth", "hcTlsServerName", "hcHealthyThreshold", "hcUnhealthyThreshold" FROM 'targetHealthCheck'; + db.prepare( + ` + DROP TABLE 'targetHealthCheck'; + ` + ).run(); + db.prepare( + ` + ALTER TABLE '__new_targetHealthCheck' RENAME TO 'targetHealthCheck'; + ` + ).run(); + db.prepare( + ` + ALTER TABLE 'subscriptions' ADD 'expiresAt' integer; + ` + ).run(); + db.prepare( + ` + ALTER TABLE 'subscriptions' ADD 'trial' integer DEFAULT false; + ` + ).run(); + db.prepare( + ` + ALTER TABLE 'requestAuditLog' ADD 'siteResourceId' integer; + ` + ).run(); + db.prepare( + ` + ALTER TABLE 'sites' ADD 'networkId' integer REFERENCES networks(networkId); + ` + ).run(); + })(); + + db.pragma("foreign_keys = ON"); + + // Re-insert targetHealthCheck rows with corrected IDs: + // targetHealthCheckId is set to the same integer as targetId (1:1 mapping), + // siteId and orgId are populated from the associated target and site. + // + // Because targetHealthCheckId is AUTOINCREMENT, inserting explicit values is + // allowed, but sqlite_sequence must be updated afterwards so future + // auto-increments don't reuse or collide with these IDs. + if (existingHealthChecks.length > 0) { + const insertHealthCheck = db.prepare( + `INSERT INTO 'targetHealthCheck' ( + "targetHealthCheckId", + "targetId", + "orgId", + "siteId", + "hcEnabled", + "hcPath", + "hcScheme", + "hcMode", + "hcHostname", + "hcPort", + "hcInterval", + "hcUnhealthyInterval", + "hcTimeout", + "hcHeaders", + "hcFollowRedirects", + "hcMethod", + "hcStatus", + "hcHealth", + "hcTlsServerName" + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ); + + const insertAll = db.transaction(() => { + for (const hc of existingHealthChecks) { + insertHealthCheck.run( + hc.targetId, // targetHealthCheckId = targetId (explicit, non-sequential is fine) + hc.targetId, + hc.orgId, + hc.siteId, + hc.hcEnabled, + hc.hcPath, + hc.hcScheme, + hc.hcMode, + hc.hcHostname, + hc.hcPort, + hc.hcInterval, + hc.hcUnhealthyInterval, + hc.hcTimeout, + hc.hcHeaders, + hc.hcFollowRedirects, + hc.hcMethod, + hc.hcStatus, + hc.hcHealth, + hc.hcTlsServerName + ); + } + }); + + insertAll(); + + // Ensure sqlite_sequence reflects the true max so that future + // AUTOINCREMENT inserts never reuse one of the explicitly-set IDs. + // INSERT OR IGNORE handles the case where no auto-insert has happened + // yet and the row doesn't exist in sqlite_sequence. + db.prepare( + `INSERT OR IGNORE INTO sqlite_sequence (name, seq) VALUES ('targetHealthCheck', 0)` + ).run(); + db.prepare( + `UPDATE sqlite_sequence + SET seq = MAX(seq, (SELECT COALESCE(MAX("targetHealthCheckId"), 0) FROM 'targetHealthCheck')) + WHERE name = 'targetHealthCheck'` + ).run(); + + console.log( + `Migrated ${existingHealthChecks.length} targetHealthCheck row(s) with corrected IDs` + ); + } + + console.log(`Migrated database`); + } catch (e) { + console.log("Failed to migrate db:", e); + throw e; + } + + console.log(`${version} migration complete`); +}