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;