diff --git a/docs/Plans/PLAN-product-updates.md b/docs/Plans/PLAN-product-updates.md
new file mode 100644
index 00000000..05493b9c
--- /dev/null
+++ b/docs/Plans/PLAN-product-updates.md
@@ -0,0 +1,125 @@
+
+# Phase: Product Updates & Release Notes (v0.63)
+
+## Ziel
+
+Sichtbar machen, wenn eine neuere Version eines installierten Produkts verfügbar ist, und die Release Notes direkt im UI zugänglich machen. Entscheidung "upgraden ja/nein" bekommt damit Kontext.
+
+Heute bleibt ein auf v1.0.2 installiertes Produkt auch nach Sync einer v1.1.0 stumm — weder Badge, noch Notification, noch Release-Notes. Diese Phase schließt die Lücke.
+
+## Analyse
+
+### Bestehende Architektur
+
+- **ProductDeployment-Aggregate** ([ProductDeployment.cs:32](../../src/ReadyStackGo.Domain/Deployment/ProductDeployments/ProductDeployment.cs#L32)) hält `ProductVersion` (installiert) und `PreviousVersion`. Kein Feld für "verfügbare/neueste Version" — das kommt aus dem Katalog.
+- **ProductDefinition** ([ProductDefinition.cs:53](../../src/ReadyStackGo.Domain/StackManagement/Stacks/ProductDefinition.cs#L53)) trägt pro Katalog-Eintrag genau eine `ProductVersion`. Sync-Zyklen überschreiben, es gibt keine Versions-Historie pro Produkt.
+- **Source-Sync** ([SyncStackSourcesHandler.cs](../../src/ReadyStackGo.Application/UseCases/StackSources/SyncStackSources/SyncStackSourcesHandler.cs)) lädt `ProductDefinition`s. Eine Source kann heute mehrere Versionen desselben Produkts nebeneinander halten (anders als "immer nur eine" — das ist eine Einschränkung, die wir adressieren müssen).
+- **RSGO-Self-Update-Muster** ([GetVersionHandler.cs:59-92](../../src/ReadyStackGo.Application/UseCases/System/GetVersion/GetVersionHandler.cs#L59-L92)) zeigt das exakte Pattern, das wir für Produkte spiegeln: `IVersionCheckService.GetLatestVersionAsync()` → `IsNewerVersion()` (SemVer) → `ExistsAsync(type, metadataKey, metadataValue)` gegen Duplikat → einmalige `Notification` mit `metadata["latestVersion"]`.
+- **Notification-Factory** ([NotificationFactory.cs](../../src/ReadyStackGo.Application/Notifications/NotificationFactory.cs)) hat `CreateProductDeploymentResult` als nächstliegende Vorlage. Kein Helper für ProductUpdateAvailable — der kommt in dieser Phase dazu. Enum `NotificationType` ([Notification.cs](../../src/ReadyStackGo.Application/Notifications/Notification.cs)) wird um `ProductUpdateAvailable` erweitert.
+- **ProductDeployment UI** ([ProductDeploymentDetail.tsx:128-130](../../src/ReadyStackGo.WebUi/packages/ui-generic/src/pages/Deployments/ProductDeploymentDetail.tsx#L128)) zeigt die installierte Version als Badge; der "View in Catalog"-Button verweist zurück auf den Katalog. An diese Stelle kommt der Update-Badge.
+
+### Betroffene Bounded Contexts
+
+- **Domain**
+ - Neues Value Object `AvailableVersion` (Version + optional `ReleaseNotesUrl` + optional `ChangelogMarkdown`) oder Felder direkt an `ProductDefinition`.
+ - Neuer Domain-Service/-Query `ProductUpdateAvailability` (vergleicht installiert vs. neueste im Katalog).
+ - Neuer Notification-Type `ProductUpdateAvailable`.
+- **Application**
+ - Query `GetProductUpdateStatus(productDeploymentId)` → `{ currentVersion, latestVersion?, hasUpdate, releaseNotesUrl?, changelogMarkdown? }`.
+ - Background-Check beim Source-Sync: nach erfolgreichem `SyncStackSources`/`SyncSingleSource` wird pro betroffenem `ProductDeployment` geprüft, ob es eine neuere Version gibt; falls ja → einmalige Notification.
+ - `NotificationFactory.CreateProductUpdateAvailable(...)` mit Metadata-Key `productDeploymentId:latestVersion` für Dedup.
+- **Infrastructure**
+ - Stack-Source-Loader (Git, OCI) liest zusätzlich `releaseNotesUrl` aus dem YAML und sucht nach `CHANGELOG.md` neben der Stack-Definition; beides wird an der `ProductDefinition` gespeichert.
+- **API**
+ - `GET /api/product-deployments/{id}/update-status` → JSON wie oben.
+ - `GET /api/product-deployments/{id}/release-notes?version=X.Y.Z` → entweder serverseitig gefetchte Markdown-Source (bei CHANGELOG.md) oder nur die URL (bei `releaseNotesUrl`) — frontend entscheidet dann zwischen Embed und externem Link.
+- **WebUi (rsgo-generic)**
+ - `ProductDeploymentDetail`: Update-Badge neben Versions-Badge; Klick öffnet Release-Notes-Viewer oder externen Link.
+ - `ProductDeployments`/Dashboard-Liste: Indikator (Punkt/Badge) auf Deployments mit verfügbarem Update.
+ - Neue Komponente `ReleaseNotesViewer` — rendert Markdown (reuse `@rsgo/core` Markdown-Util falls vorhanden, sonst `react-markdown` oder `marked` — Library-Entscheidung siehe "Offene Punkte").
+
+### YAML-Schema-Änderung
+
+Ergänzt in `ProductDefinition` YAML:
+```yaml
+productVersion: "1.1.0"
+releaseNotesUrl: "https://github.com/org/product/releases/tag/v1.1.0" # optional
+# Konvention: liegt CHANGELOG.md im gleichen Verzeichnis → wird automatisch
+# beim Sync eingelesen und bevorzugt vor releaseNotesUrl im Viewer angezeigt.
+```
+
+## AMS UI Counterpart
+
+**Ja — AMS-Counterpart wird als eigenes PLAN file angelegt** (`C:\proj\ReadyStackGo.Ams\docs\Plans\PLAN-product-updates.md`). Shared Hooks (`useProductUpdateStatus`, `useReleaseNotes`) kommen in `@rsgo/core`; Pages/Komponenten werden im AMS-Distribution mit ConsistentUI/Lit reimplementiert.
+
+## Features / Schritte
+
+Reihenfolge basierend auf Abhängigkeiten:
+
+- [ ] **Feature 1: YAML-Schema + Source-Loader**
+ - `ProductDefinition` bekommt `ReleaseNotesUrl?` und `ChangelogMarkdown?`.
+ - Git-Source-Loader und OCI-Source-Loader lesen beides beim Sync.
+ - Betroffene Dateien: `src/ReadyStackGo.Domain/StackManagement/Stacks/ProductDefinition.cs`, alle Source-Loader unter `src/ReadyStackGo.Infrastructure/StackSources/`.
+ - Pattern-Vorlage: bestehender `ProductDefinition`-Parser.
+ - Abhängig von: —
+- [ ] **Feature 2: Update-Status-Query**
+ - Query `GetProductUpdateStatus` + Handler; vergleicht installiert vs. neueste verfügbare Version via SemVer (reuse `IsNewerVersion` aus [VersionCheckService.cs:94](../../src/ReadyStackGo.Infrastructure/Services/VersionCheckService.cs#L94) — extrahieren oder wiederverwenden).
+ - API-Endpoint `GET /api/product-deployments/{id}/update-status`.
+ - Betroffene Dateien: `src/ReadyStackGo.Application/UseCases/ProductDeployments/GetUpdateStatus/...`, `src/ReadyStackGo.Api/Endpoints/ProductDeployments/GetUpdateStatusEndpoint.cs`.
+ - Abhängig von: Feature 1.
+- [ ] **Feature 3: Release-Notes-Endpoint**
+ - `GET /api/product-deployments/{id}/release-notes?version=X.Y.Z` liefert `{ mode: "markdown" | "url", content: "...", url?: "..." }`.
+ - Betroffene Dateien: `src/ReadyStackGo.Api/Endpoints/ProductDeployments/GetReleaseNotesEndpoint.cs`, Query-Handler.
+ - Abhängig von: Feature 1.
+- [ ] **Feature 4: Update-Notification + Dedup**
+ - Neuer `NotificationType.ProductUpdateAvailable` + `NotificationFactory.CreateProductUpdateAvailable`.
+ - Hook am Ende von `SyncStackSourcesHandler`/`SyncSingleSourceEndpoint`: iteriert aktive `ProductDeployment`s, prüft `GetProductUpdateStatus`, ruft bei neuer Version `AddAsync` (mit `ExistsAsync`-Dedup auf `{productDeploymentId}:{latestVersion}`).
+ - Betroffene Dateien: `src/ReadyStackGo.Application/Notifications/Notification.cs`, `NotificationFactory.cs`, Sync-Handler.
+ - Pattern-Vorlage: [GetVersionHandler.cs:59-92](../../src/ReadyStackGo.Application/UseCases/System/GetVersion/GetVersionHandler.cs#L59).
+ - Abhängig von: Feature 2.
+- [ ] **Feature 5: UI — `@rsgo/core` Hooks + API-Client**
+ - `notificationsApi`-Parallele: `productUpdatesApi.getStatus(id)`, `productUpdatesApi.getReleaseNotes(id, version)`.
+ - Hooks: `useProductUpdateStatus(id)`, `useReleaseNotes(id, version)`.
+ - Betroffene Dateien: `src/ReadyStackGo.WebUi/packages/core/src/api/productUpdates.ts`, `hooks/useProductUpdateStatus.ts`, `hooks/useReleaseNotes.ts`.
+ - Abhängig von: Features 2–3.
+- [ ] **Feature 6: UI — Badge + Viewer + Dashboard**
+ - Update-Badge neben Versions-Badge in `ProductDeploymentDetail.tsx`.
+ - Neue Komponente `ReleaseNotesViewer` (Modal oder Seiten-Sektion).
+ - Dashboard/Overview: Indikator auf Zeilen mit `hasUpdate: true`.
+ - Betroffene Dateien: `src/ReadyStackGo.WebUi/packages/ui-generic/src/pages/Deployments/ProductDeploymentDetail.tsx`, `src/ReadyStackGo.WebUi/packages/ui-generic/src/components/ReleaseNotesViewer.tsx`, Dashboard-Liste.
+ - Abhängig von: Feature 5.
+- [ ] **Feature 7: AMS UI Counterpart** — separates PLAN file im AMS Repo, parallel zu Features 5-6 mit ConsistentUI/Lit-Komponenten.
+- [ ] **Dokumentation & Website** — Wiki-Seite "Product Updates", Public-Website-Update (DE/EN), Roadmap-Eintrag, Beispiel-YAML mit `releaseNotesUrl` in `docs/Reference/`.
+- [ ] **Phase abschließen** — Alle Tests grün, v0.63-Release-Notes, PR gegen main.
+
+## Test-Strategie
+
+- **Unit Tests**
+ - `ProductDefinitionParser`: parst `releaseNotesUrl`, findet `CHANGELOG.md` beim Sync; kein Wert → null-Felder.
+ - SemVer-Compare: v1.0.2 < v1.1.0, v1.10.0 > v1.9.9, Prerelease (v1.1.0-rc1) vs. Stable, identische Version → kein Update.
+ - `NotificationFactory.CreateProductUpdateAvailable`: Severity, Title/Message, Metadata-Keys.
+ - Dedup: `ExistsAsync({productDeploymentId}:{latestVersion})` unterdrückt Zweit-Sync.
+- **Integration Tests**
+ - `/api/product-deployments/{id}/update-status`: Response-Shape, 404 bei unbekannter ID, leerer Katalog → `hasUpdate: false`.
+ - `/api/product-deployments/{id}/release-notes?version=X`: Markdown-Mode bei CHANGELOG.md vorhanden, URL-Mode bei nur `releaseNotesUrl`, 404 sonst.
+- **E2E Tests** (Playwright)
+ - Zwei Produkte im Katalog (v1.0.0 + v1.1.0), ein Deployment auf v1.0.0 → nach Sync: Update-Badge auf Detail-Seite, Dashboard-Indicator, ein Notification-Eintrag.
+ - Klick auf Badge öffnet `ReleaseNotesViewer` mit gerendertem Markdown.
+ - Zweiter Sync → keine zweite Notification (Dedup verifiziert).
+
+## Offene Punkte
+
+- [ ] **Markdown-Library**: `react-markdown` (sicher, modular) vs. `marked` (schlank, weniger Deps). Entscheidung beim ersten Implementierungs-PR.
+- [ ] **Release-Notes-Aggregation**: Wenn zwischen installiert (v1.0.2) und neuester (v1.3.0) mehrere Versionen liegen — alle Changelogs anzeigen oder nur die der Ziel-Version? Empfehlung: nur Ziel-Version; Aggregation als Folge-Feature.
+- [ ] **Multi-Version im Katalog**: Heute hält `ProductDefinition` eine Version. Muss der Sync mehrere Versions-Einträge pro Produkt speichern? Klärung beim Refinement von Feature 1.
+- [ ] **Sicherheit Release-Notes-Viewer**: Externe URLs sollen nicht serverseitig gefetcht werden (SSRF-Risiko). CHANGELOG.md aus eigenen Sources ist ok; externe URLs werden nur als Link gerendert, nicht im Viewer embedded.
+
+## Entscheidungen
+
+| Entscheidung | Optionen | Gewählt | Begründung |
+|---|---|---|---|
+| Milestone | v0.60 / v1.0 / v0.63 | **v0.63** | v0.60 bereits als "Complete Health Check Support" vergeben und geschlossen; v0.61/v0.62 ebenso. v0.63 ist der nächste freie. |
+| Release-Notes-Quelle | URL / Markdown / Git-Tag / CHANGELOG.md | **URL + CHANGELOG.md** | Vom User gewählt. Source-agnostisch, einfach, CHANGELOG.md als etablierte Konvention. |
+| UI-Scope | Badge / Badge+Notif / Komplett | **Komplett** | Vom User gewählt: Badge + Notification + Dashboard + Release-Notes-Viewer. |
+| Update-Scope | Strikt SemVer-newer / Alle ≠ installiert | **Strikt newer** | Vom User gewählt. Konsistent zum RSGO-self-update, kein Downgrade-Noise. |
+| AMS-Counterpart | Ja / Deferred / Nein / Teilweise | **Ja (eigener PLAN)** | UI-Komponenten betroffen; separates PLAN im AMS-Repo, shared Hooks in `@rsgo/core`. |
diff --git a/src/ReadyStackGo.Api/BackgroundServices/HealthCollectorBackgroundService.cs b/src/ReadyStackGo.Api/BackgroundServices/HealthCollectorBackgroundService.cs
index 0a19888c..1baa5097 100644
--- a/src/ReadyStackGo.Api/BackgroundServices/HealthCollectorBackgroundService.cs
+++ b/src/ReadyStackGo.Api/BackgroundServices/HealthCollectorBackgroundService.cs
@@ -213,7 +213,9 @@ public class HealthCollectorOptions
///
/// Number of days to retain health snapshots.
/// Snapshots older than this are automatically deleted.
- /// Default: 30 days.
+ /// Default: 7 days. With a 30s collection interval this still produces ~20k snapshots
+ /// per deployment; longer retention is unnecessary because the UI history view
+ /// only requests the latest 50 snapshots per deployment.
///
- public int RetentionDays { get; set; } = 30;
+ public int RetentionDays { get; set; } = 7;
}
diff --git a/src/ReadyStackGo.Infrastructure.DataAccess/Configurations/HealthSnapshotConfiguration.cs b/src/ReadyStackGo.Infrastructure.DataAccess/Configurations/HealthSnapshotConfiguration.cs
index 91a66500..9c39f04c 100644
--- a/src/ReadyStackGo.Infrastructure.DataAccess/Configurations/HealthSnapshotConfiguration.cs
+++ b/src/ReadyStackGo.Infrastructure.DataAccess/Configurations/HealthSnapshotConfiguration.cs
@@ -109,10 +109,14 @@ public void Configure(EntityTypeBuilder builder)
// Indexes for efficient querying
builder.HasIndex(h => h.DeploymentId);
- builder.HasIndex(h => h.EnvironmentId);
builder.HasIndex(h => h.CapturedAtUtc);
builder.HasIndex(h => new { h.DeploymentId, h.CapturedAtUtc });
+ // Composite index covering the "latest snapshot per deployment in environment"
+ // query in HealthSnapshotRepository.GetLatestForEnvironment. Replaces the
+ // single-column EnvironmentId index, which is now redundant.
+ builder.HasIndex(h => new { h.EnvironmentId, h.DeploymentId, h.CapturedAtUtc });
+
// Ignore domain events (not persisted)
builder.Ignore(h => h.DomainEvents);
}
diff --git a/src/ReadyStackGo.Infrastructure.DataAccess/Migrations/20260505093951_AddHealthSnapshotsCompositeIndex.Designer.cs b/src/ReadyStackGo.Infrastructure.DataAccess/Migrations/20260505093951_AddHealthSnapshotsCompositeIndex.Designer.cs
new file mode 100644
index 00000000..aeb07305
--- /dev/null
+++ b/src/ReadyStackGo.Infrastructure.DataAccess/Migrations/20260505093951_AddHealthSnapshotsCompositeIndex.Designer.cs
@@ -0,0 +1,968 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using ReadyStackGo.Infrastructure.DataAccess;
+
+#nullable disable
+
+namespace ReadyStackGo.Infrastructure.DataAccess.Migrations
+{
+ [DbContext(typeof(ReadyStackGoDbContext))]
+ [Migration("20260505093951_AddHealthSnapshotsCompositeIndex")]
+ partial class AddHealthSnapshotsCompositeIndex
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "9.0.0");
+
+ modelBuilder.Entity("ReadyStackGo.Domain.Deployment.Deployments.Deployment", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("CancellationReason")
+ .HasColumnType("TEXT");
+
+ b.Property("CompletedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("CurrentPhase")
+ .HasColumnType("INTEGER");
+
+ b.Property("DeployedBy")
+ .HasColumnType("TEXT");
+
+ b.Property("EnvironmentId")
+ .HasColumnType("TEXT");
+
+ b.Property("ErrorMessage")
+ .HasMaxLength(2000)
+ .HasColumnType("TEXT");
+
+ b.Property("HealthCheckConfigs")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("HealthCheckConfigsJson");
+
+ b.Property("InitContainerResults")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("InitContainerResultsJson");
+
+ b.Property("IsCancellationRequested")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastUpgradedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("MaintenanceObserverConfig")
+ .HasColumnType("TEXT")
+ .HasColumnName("MaintenanceObserverConfigJson");
+
+ b.Property("MaintenanceTrigger")
+ .HasColumnType("TEXT")
+ .HasColumnName("MaintenanceTriggerJson");
+
+ b.Property("OperationMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("PreviousVersion")
+ .HasMaxLength(50)
+ .HasColumnType("TEXT");
+
+ b.Property("ProgressMessage")
+ .HasColumnType("TEXT");
+
+ b.Property("ProgressPercentage")
+ .HasColumnType("INTEGER");
+
+ b.Property("ProjectName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("StackId")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b.Property("StackName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("StackVersion")
+ .HasMaxLength(50)
+ .HasColumnType("TEXT");
+
+ b.Property("Status")
+ .HasColumnType("INTEGER");
+
+ b.Property("UpgradeCount")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(0);
+
+ b.Property("Variables")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("VariablesJson");
+
+ b.Property("Version")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("EnvironmentId");
+
+ b.HasIndex("Status");
+
+ b.ToTable("Deployments", (string)null);
+ });
+
+ modelBuilder.Entity("ReadyStackGo.Domain.Deployment.Environments.Environment", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("ConnectionConfig")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("ConnectionConfigJson");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Description")
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("IsDefault")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("OrganizationId")
+ .HasColumnType("TEXT");
+
+ b.Property("SshCredential")
+ .HasColumnType("TEXT")
+ .HasColumnName("SshCredentialJson");
+
+ b.Property("Type")
+ .HasColumnType("INTEGER");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Version")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OrganizationId");
+
+ b.HasIndex("OrganizationId", "Name")
+ .IsUnique();
+
+ b.ToTable("Environments", (string)null);
+ });
+
+ modelBuilder.Entity("ReadyStackGo.Domain.Deployment.Environments.EnvironmentVariable", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("EnvironmentId")
+ .HasColumnType("TEXT");
+
+ b.Property("IsEncrypted")
+ .HasColumnType("INTEGER");
+
+ b.Property("Key")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("EnvironmentId");
+
+ b.HasIndex("EnvironmentId", "Key")
+ .IsUnique();
+
+ b.ToTable("EnvironmentVariables", (string)null);
+ });
+
+ modelBuilder.Entity("ReadyStackGo.Domain.Deployment.Health.HealthSnapshot", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("Bus")
+ .HasColumnType("TEXT")
+ .HasColumnName("BusHealthJson");
+
+ b.Property("CapturedAtUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("CurrentVersion")
+ .HasMaxLength(50)
+ .HasColumnType("TEXT");
+
+ b.Property("DeploymentId")
+ .HasColumnType("TEXT");
+
+ b.Property("EnvironmentId")
+ .HasColumnType("TEXT");
+
+ b.Property("Infra")
+ .HasColumnType("TEXT")
+ .HasColumnName("InfraHealthJson");
+
+ b.Property("OperationMode")
+ .HasColumnType("INTEGER")
+ .HasColumnName("OperationMode");
+
+ b.Property("OrganizationId")
+ .HasColumnType("TEXT");
+
+ b.Property("Overall")
+ .HasColumnType("INTEGER")
+ .HasColumnName("OverallStatus");
+
+ b.Property("Self")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("SelfHealthJson");
+
+ b.Property("StackName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("TargetVersion")
+ .HasMaxLength(50)
+ .HasColumnType("TEXT");
+
+ b.Property("Version")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CapturedAtUtc");
+
+ b.HasIndex("DeploymentId");
+
+ b.HasIndex("DeploymentId", "CapturedAtUtc");
+
+ b.HasIndex("EnvironmentId", "DeploymentId", "CapturedAtUtc");
+
+ b.ToTable("HealthSnapshots", (string)null);
+ });
+
+ modelBuilder.Entity("ReadyStackGo.Domain.Deployment.ProductDeployments.ProductDeployment", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("CompletedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("ContinueOnError")
+ .HasColumnType("INTEGER");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("DeployedBy")
+ .HasColumnType("TEXT");
+
+ b.Property("DeploymentName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("EnvironmentId")
+ .HasColumnType("TEXT");
+
+ b.Property("ErrorMessage")
+ .HasMaxLength(2000)
+ .HasColumnType("TEXT");
+
+ b.Property("LastUpgradedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("MaintenanceObserverConfig")
+ .HasColumnType("TEXT")
+ .HasColumnName("MaintenanceObserverConfigJson");
+
+ b.Property("MaintenanceTrigger")
+ .HasColumnType("TEXT")
+ .HasColumnName("MaintenanceTriggerJson");
+
+ b.Property("OperationMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("PhaseHistory")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("PhaseHistoryJson");
+
+ b.Property("PreviousVersion")
+ .HasMaxLength(50)
+ .HasColumnType("TEXT");
+
+ b.Property("ProductDisplayName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b.Property("ProductGroupId")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("ProductId")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("ProductName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b.Property("ProductVersion")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("TEXT");
+
+ b.Property("SharedVariables")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("SharedVariablesJson");
+
+ b.Property("Status")
+ .HasColumnType("INTEGER");
+
+ b.Property("UpgradeCount")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(0);
+
+ b.Property("Version")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("EnvironmentId");
+
+ b.HasIndex("ProductGroupId");
+
+ b.HasIndex("Status");
+
+ b.ToTable("ProductDeployments", (string)null);
+ });
+
+ modelBuilder.Entity("ReadyStackGo.Domain.Deployment.Registries.Registry", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("IsDefault")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("OrganizationId")
+ .HasColumnType("TEXT");
+
+ b.Property("Password")
+ .HasMaxLength(1000)
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Url")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("Username")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property("Version")
+ .HasColumnType("INTEGER");
+
+ b.Property("_imagePatterns")
+ .IsRequired()
+ .HasMaxLength(4000)
+ .HasColumnType("TEXT")
+ .HasColumnName("ImagePatterns");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OrganizationId");
+
+ b.HasIndex("OrganizationId", "IsDefault")
+ .HasFilter("[IsDefault] = 1");
+
+ b.HasIndex("OrganizationId", "Name")
+ .IsUnique();
+
+ b.ToTable("Registries", (string)null);
+ });
+
+ modelBuilder.Entity("ReadyStackGo.Domain.IdentityAccess.ApiKeys.ApiKey", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("EnvironmentId")
+ .HasColumnType("TEXT");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("TEXT");
+
+ b.Property("IsRevoked")
+ .HasColumnType("INTEGER");
+
+ b.Property("KeyHash")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("KeyPrefix")
+ .IsRequired()
+ .HasMaxLength(12)
+ .HasColumnType("TEXT");
+
+ b.Property("LastUsedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("OrganizationId")
+ .HasColumnType("TEXT");
+
+ b.Property("RevokedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("RevokedReason")
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("Version")
+ .HasColumnType("INTEGER");
+
+ b.Property("_permissions")
+ .IsRequired()
+ .HasMaxLength(4000)
+ .HasColumnType("TEXT")
+ .HasColumnName("Permissions");
+
+ b.HasKey("Id");
+
+ b.HasIndex("KeyHash")
+ .IsUnique();
+
+ b.HasIndex("OrganizationId");
+
+ b.HasIndex("OrganizationId", "Name")
+ .IsUnique();
+
+ b.ToTable("ApiKeys", (string)null);
+ });
+
+ modelBuilder.Entity("ReadyStackGo.Domain.IdentityAccess.Organizations.Organization", b =>
+ {
+ b.Property("Id")
+ .HasMaxLength(36)
+ .HasColumnType("TEXT")
+ .HasColumnName("Id");
+
+ b.Property("Active")
+ .HasColumnType("INTEGER");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("OwnerId")
+ .HasMaxLength(36)
+ .HasColumnType("TEXT")
+ .HasColumnName("OwnerId");
+
+ b.Property("Version")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("Organizations", (string)null);
+ });
+
+ modelBuilder.Entity("ReadyStackGo.Domain.IdentityAccess.Users.User", b =>
+ {
+ b.Property("Id")
+ .HasMaxLength(36)
+ .HasColumnType("TEXT")
+ .HasColumnName("Id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("FailedLoginAttempts")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("LockReason")
+ .HasColumnType("TEXT");
+
+ b.Property("LockedUntil")
+ .HasColumnType("TEXT");
+
+ b.Property("MustChangePassword")
+ .HasColumnType("INTEGER");
+
+ b.Property("PasswordChangedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("TEXT");
+
+ b.Property("Version")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Username")
+ .IsUnique();
+
+ b.ToTable("Users", (string)null);
+ });
+
+ modelBuilder.Entity("ReadyStackGo.Domain.StackManagement.Sources.StackSource", b =>
+ {
+ b.Property("Id")
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Enabled")
+ .HasColumnType("INTEGER");
+
+ b.Property("FilePattern")
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b.Property("GitBranch")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property("GitPassword")
+ .HasMaxLength(1000)
+ .HasColumnType("TEXT");
+
+ b.Property("GitSslVerify")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(true);
+
+ b.Property("GitUrl")
+ .HasMaxLength(1000)
+ .HasColumnType("TEXT");
+
+ b.Property("GitUsername")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property("LastSyncedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("Path")
+ .HasMaxLength(1000)
+ .HasColumnType("TEXT");
+
+ b.Property("RegistryPassword")
+ .HasMaxLength(1000)
+ .HasColumnType("TEXT");
+
+ b.Property("RegistryUrl")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property("RegistryUsername")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property("Repository")
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("TagPattern")
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("TEXT");
+
+ b.Property("Version")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Enabled");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.HasIndex("Type");
+
+ b.ToTable("StackSources", (string)null);
+ });
+
+ modelBuilder.Entity("ReadyStackGo.Domain.Deployment.Deployments.Deployment", b =>
+ {
+ b.OwnsMany("ReadyStackGo.Domain.Deployment.Deployments.DeployedService", "Services", b1 =>
+ {
+ b1.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b1.Property("ContainerId")
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b1.Property("ContainerName")
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b1.Property("DeploymentId")
+ .HasColumnType("TEXT");
+
+ b1.Property("Image")
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b1.Property("ServiceName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b1.Property("Status")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("TEXT");
+
+ b1.HasKey("Id");
+
+ b1.HasIndex("DeploymentId");
+
+ b1.ToTable("DeployedServices", (string)null);
+
+ b1.WithOwner()
+ .HasForeignKey("DeploymentId");
+ });
+
+ b.OwnsMany("ReadyStackGo.Domain.Deployment.Deployments.DeploymentPhaseRecord", "PhaseHistory", b1 =>
+ {
+ b1.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b1.Property("DeploymentId")
+ .HasColumnType("TEXT");
+
+ b1.Property("Message")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b1.Property("Phase")
+ .HasColumnType("INTEGER");
+
+ b1.Property("Timestamp")
+ .HasColumnType("TEXT");
+
+ b1.HasKey("Id");
+
+ b1.HasIndex("DeploymentId");
+
+ b1.ToTable("DeploymentPhaseHistory", (string)null);
+
+ b1.WithOwner()
+ .HasForeignKey("DeploymentId");
+ });
+
+ b.Navigation("PhaseHistory");
+
+ b.Navigation("Services");
+ });
+
+ modelBuilder.Entity("ReadyStackGo.Domain.Deployment.ProductDeployments.ProductDeployment", b =>
+ {
+ b.OwnsMany("ReadyStackGo.Domain.Deployment.ProductDeployments.ProductStackDeployment", "Stacks", b1 =>
+ {
+ b1.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b1.Property("CompletedAt")
+ .HasColumnType("TEXT");
+
+ b1.Property("DeploymentId")
+ .HasColumnType("TEXT");
+
+ b1.Property("DeploymentStackName")
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b1.Property("ErrorMessage")
+ .HasMaxLength(2000)
+ .HasColumnType("TEXT");
+
+ b1.Property("IsNewInUpgrade")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(false);
+
+ b1.Property("Order")
+ .HasColumnType("INTEGER");
+
+ b1.Property("ProductDeploymentId")
+ .HasColumnType("TEXT");
+
+ b1.Property("ServiceCount")
+ .HasColumnType("INTEGER");
+
+ b1.Property("StackDisplayName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b1.Property("StackId")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b1.Property("StackName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("TEXT");
+
+ b1.Property("StartedAt")
+ .HasColumnType("TEXT");
+
+ b1.Property("Status")
+ .HasColumnType("INTEGER");
+
+ b1.Property("Variables")
+ .IsRequired()
+ .HasColumnType("TEXT")
+ .HasColumnName("VariablesJson");
+
+ b1.HasKey("Id");
+
+ b1.HasIndex("DeploymentId");
+
+ b1.HasIndex("ProductDeploymentId");
+
+ b1.ToTable("ProductStackDeployments", (string)null);
+
+ b1.WithOwner()
+ .HasForeignKey("ProductDeploymentId");
+ });
+
+ b.Navigation("Stacks");
+ });
+
+ modelBuilder.Entity("ReadyStackGo.Domain.IdentityAccess.Users.User", b =>
+ {
+ b.OwnsOne("ReadyStackGo.Domain.IdentityAccess.Users.EmailAddress", "Email", b1 =>
+ {
+ b1.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b1.Property("Value")
+ .IsRequired()
+ .HasMaxLength(254)
+ .HasColumnType("TEXT")
+ .HasColumnName("Email");
+
+ b1.HasKey("UserId");
+
+ b1.HasIndex("Value")
+ .IsUnique();
+
+ b1.ToTable("Users");
+
+ b1.WithOwner()
+ .HasForeignKey("UserId");
+ });
+
+ b.OwnsOne("ReadyStackGo.Domain.IdentityAccess.Users.Enablement", "Enablement", b1 =>
+ {
+ b1.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b1.Property("Enabled")
+ .HasColumnType("INTEGER")
+ .HasColumnName("Enabled");
+
+ b1.Property("EndDate")
+ .HasColumnType("TEXT")
+ .HasColumnName("EnablementEndDate");
+
+ b1.Property("StartDate")
+ .HasColumnType("TEXT")
+ .HasColumnName("EnablementStartDate");
+
+ b1.HasKey("UserId");
+
+ b1.ToTable("Users");
+
+ b1.WithOwner()
+ .HasForeignKey("UserId");
+ });
+
+ b.OwnsOne("ReadyStackGo.Domain.IdentityAccess.Users.HashedPassword", "Password", b1 =>
+ {
+ b1.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b1.Property("Hash")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("TEXT")
+ .HasColumnName("PasswordHash");
+
+ b1.HasKey("UserId");
+
+ b1.ToTable("Users");
+
+ b1.WithOwner()
+ .HasForeignKey("UserId");
+ });
+
+ b.OwnsMany("ReadyStackGo.Domain.IdentityAccess.Users.RoleAssignment", "RoleAssignments", b1 =>
+ {
+ b1.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b1.Property("AssignedAt")
+ .HasColumnType("TEXT")
+ .HasColumnName("AssignedAt");
+
+ b1.Property("RoleId")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("TEXT")
+ .HasColumnName("RoleId");
+
+ b1.Property("ScopeId")
+ .HasMaxLength(36)
+ .HasColumnType("TEXT")
+ .HasColumnName("ScopeId");
+
+ b1.Property("ScopeType")
+ .HasColumnType("INTEGER")
+ .HasColumnName("ScopeType");
+
+ b1.Property("UserId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b1.HasKey("Id");
+
+ b1.HasIndex("UserId", "RoleId", "ScopeType", "ScopeId")
+ .IsUnique();
+
+ b1.ToTable("UserRoles", (string)null);
+
+ b1.WithOwner()
+ .HasForeignKey("UserId");
+ });
+
+ b.Navigation("Email")
+ .IsRequired();
+
+ b.Navigation("Enablement")
+ .IsRequired();
+
+ b.Navigation("Password")
+ .IsRequired();
+
+ b.Navigation("RoleAssignments");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/ReadyStackGo.Infrastructure.DataAccess/Migrations/20260505093951_AddHealthSnapshotsCompositeIndex.cs b/src/ReadyStackGo.Infrastructure.DataAccess/Migrations/20260505093951_AddHealthSnapshotsCompositeIndex.cs
new file mode 100644
index 00000000..7c7c5c07
--- /dev/null
+++ b/src/ReadyStackGo.Infrastructure.DataAccess/Migrations/20260505093951_AddHealthSnapshotsCompositeIndex.cs
@@ -0,0 +1,38 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace ReadyStackGo.Infrastructure.DataAccess.Migrations
+{
+ ///
+ public partial class AddHealthSnapshotsCompositeIndex : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ // Use IF EXISTS / IF NOT EXISTS so the migration is safe against
+ // legacy databases where the baseline schema may not have produced the
+ // exact same set of indexes that the InitialCreate migration declares.
+ migrationBuilder.Sql(
+ "DROP INDEX IF EXISTS \"IX_HealthSnapshots_EnvironmentId\";");
+
+ migrationBuilder.Sql(
+ "CREATE INDEX IF NOT EXISTS " +
+ "\"IX_HealthSnapshots_EnvironmentId_DeploymentId_CapturedAtUtc\" " +
+ "ON \"HealthSnapshots\" (\"EnvironmentId\", \"DeploymentId\", \"CapturedAtUtc\");");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.Sql(
+ "DROP INDEX IF EXISTS " +
+ "\"IX_HealthSnapshots_EnvironmentId_DeploymentId_CapturedAtUtc\";");
+
+ migrationBuilder.Sql(
+ "CREATE INDEX IF NOT EXISTS " +
+ "\"IX_HealthSnapshots_EnvironmentId\" " +
+ "ON \"HealthSnapshots\" (\"EnvironmentId\");");
+ }
+ }
+}
diff --git a/src/ReadyStackGo.Infrastructure.DataAccess/Migrations/ReadyStackGoDbContextModelSnapshot.cs b/src/ReadyStackGo.Infrastructure.DataAccess/Migrations/ReadyStackGoDbContextModelSnapshot.cs
index 487de398..3639ceb4 100644
--- a/src/ReadyStackGo.Infrastructure.DataAccess/Migrations/ReadyStackGoDbContextModelSnapshot.cs
+++ b/src/ReadyStackGo.Infrastructure.DataAccess/Migrations/ReadyStackGoDbContextModelSnapshot.cs
@@ -275,10 +275,10 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.HasIndex("DeploymentId");
- b.HasIndex("EnvironmentId");
-
b.HasIndex("DeploymentId", "CapturedAtUtc");
+ b.HasIndex("EnvironmentId", "DeploymentId", "CapturedAtUtc");
+
b.ToTable("HealthSnapshots", (string)null);
});
diff --git a/src/ReadyStackGo.Infrastructure.DataAccess/Repositories/HealthSnapshotRepository.cs b/src/ReadyStackGo.Infrastructure.DataAccess/Repositories/HealthSnapshotRepository.cs
index 7cbb093c..3be4aaac 100644
--- a/src/ReadyStackGo.Infrastructure.DataAccess/Repositories/HealthSnapshotRepository.cs
+++ b/src/ReadyStackGo.Infrastructure.DataAccess/Repositories/HealthSnapshotRepository.cs
@@ -45,34 +45,34 @@ public IEnumerable GetLatestForEnvironment(EnvironmentId environ
{
// Use raw SQL to efficiently get the latest snapshot per deployment.
// The EF GroupBy+First pattern causes client-side evaluation, loading ALL rows.
- // Stale snapshots from removed deployments are cleaned up at the source
- // (DeploymentService calls RemoveForDeployment when marking a deployment as removed).
- var envId = environmentId.Value.ToString().ToUpperInvariant();
+ // Pass the Guid directly so EF Core uses its standard Guid-to-TEXT conversion;
+ // the resulting parameter binding allows the composite index
+ // IX_HealthSnapshots_EnvironmentId_DeploymentId_CapturedAtUtc to be used.
+ var envId = environmentId.Value;
return _context.HealthSnapshots
- .FromSqlRaw(
- """
+ .FromSqlInterpolated(
+ $"""
SELECT h.*
FROM "HealthSnapshots" h
INNER JOIN (
SELECT "DeploymentId", MAX("CapturedAtUtc") AS "MaxDate"
FROM "HealthSnapshots"
- WHERE UPPER("EnvironmentId") = {0}
+ WHERE "EnvironmentId" = {envId}
GROUP BY "DeploymentId"
) latest ON h."DeploymentId" = latest."DeploymentId"
AND h."CapturedAtUtc" = latest."MaxDate"
- WHERE UPPER(h."EnvironmentId") = {0}
- """,
- envId)
+ WHERE h."EnvironmentId" = {envId}
+ """)
.ToList();
}
public void RemoveForDeployment(DeploymentId deploymentId)
{
- var id = deploymentId.Value.ToString().ToUpperInvariant();
+ var id = deploymentId.Value;
_context.Database.ExecuteSql(
$"""
- DELETE FROM "HealthSnapshots" WHERE UPPER("DeploymentId") = {id}
+ DELETE FROM "HealthSnapshots" WHERE "DeploymentId" = {id}
""");
}
@@ -87,20 +87,20 @@ public IEnumerable GetHistory(DeploymentId deploymentId, int lim
public IEnumerable GetTransitions(DeploymentId deploymentId)
{
- var id = deploymentId.Value.ToString().ToUpperInvariant();
+ var id = deploymentId.Value;
// Use LAG() window function to find rows where OverallStatus changed.
// Also include first snapshot (PrevStatus IS NULL) and latest (RowDesc = 1).
return _context.HealthSnapshots
- .FromSqlRaw(
- """
+ .FromSqlInterpolated(
+ $"""
WITH ranked AS (
SELECT "Id",
"OverallStatus",
LAG("OverallStatus") OVER (ORDER BY "CapturedAtUtc") AS "PrevStatus",
ROW_NUMBER() OVER (ORDER BY "CapturedAtUtc" DESC) AS "RowDesc"
FROM "HealthSnapshots"
- WHERE UPPER("DeploymentId") = {0}
+ WHERE "DeploymentId" = {id}
)
SELECT h.*
FROM "HealthSnapshots" h
@@ -109,8 +109,7 @@ WHERE r."PrevStatus" IS NULL
OR r."OverallStatus" != r."PrevStatus"
OR r."RowDesc" = 1
ORDER BY h."CapturedAtUtc" ASC
- """,
- id)
+ """)
.ToList();
}
diff --git a/tests/ReadyStackGo.UnitTests/Infrastructure/DataAccess/MigrationBaselineTests.cs b/tests/ReadyStackGo.UnitTests/Infrastructure/DataAccess/MigrationBaselineTests.cs
index c8377f4a..0b331c7b 100644
--- a/tests/ReadyStackGo.UnitTests/Infrastructure/DataAccess/MigrationBaselineTests.cs
+++ b/tests/ReadyStackGo.UnitTests/Infrastructure/DataAccess/MigrationBaselineTests.cs
@@ -135,6 +135,9 @@ private static void CreateLegacyStackSourcesTable(SqliteConnection connection)
{
// Matches the InitialCreate baseline schema — includes the Version column
// inherited from AggregateRoot for optimistic concurrency, but no OCI columns.
+ // Also creates the subset of other baseline tables/indexes that subsequent
+ // migrations may touch, so the legacy DB is a realistic stand-in for a real
+ // EnsureCreated()-era database (which would have all baseline tables).
WithOpenConnection(connection, () =>
{
using var cmd = connection.CreateCommand();
@@ -155,6 +158,28 @@ private static void CreateLegacyStackSourcesTable(SqliteConnection connection)
"GitSslVerify" INTEGER NOT NULL DEFAULT 1,
"Version" INTEGER NOT NULL DEFAULT 0
);
+
+ CREATE TABLE "HealthSnapshots" (
+ "Id" TEXT NOT NULL PRIMARY KEY,
+ "OrganizationId" TEXT NOT NULL,
+ "EnvironmentId" TEXT NOT NULL,
+ "DeploymentId" TEXT NOT NULL,
+ "StackName" TEXT NOT NULL,
+ "CapturedAtUtc" TEXT NOT NULL,
+ "CurrentVersion" TEXT NULL,
+ "TargetVersion" TEXT NULL,
+ "OverallStatus" INTEGER NOT NULL,
+ "OperationMode" INTEGER NOT NULL,
+ "SelfHealthJson" TEXT NULL,
+ "BusHealthJson" TEXT NULL,
+ "InfraHealthJson" TEXT NULL,
+ "Version" INTEGER NOT NULL DEFAULT 0
+ );
+ CREATE INDEX "IX_HealthSnapshots_DeploymentId" ON "HealthSnapshots" ("DeploymentId");
+ CREATE INDEX "IX_HealthSnapshots_EnvironmentId" ON "HealthSnapshots" ("EnvironmentId");
+ CREATE INDEX "IX_HealthSnapshots_CapturedAtUtc" ON "HealthSnapshots" ("CapturedAtUtc");
+ CREATE INDEX "IX_HealthSnapshots_DeploymentId_CapturedAtUtc"
+ ON "HealthSnapshots" ("DeploymentId", "CapturedAtUtc");
""";
cmd.ExecuteNonQuery();
return 0;