From 298a182fbb669665cd8854f9be3dba46320422ec Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Sun, 6 Sep 2026 02:34:11 +0200 Subject: [PATCH] shared+web: backup job status and restore-row payloads join the wire contract; the web reads the generated types (audit package 5, stage 4b) BackupJobStatus, RestoreRowPreviewResponse and RestoreRowCommitResponse were the last response payloads the portal typed by hand: the C# classes existed in Shared but were not marked [WireContract], so the manifest never carried them. The attribute pulls them and their enums (BackupJobKind/State, BackupOutcome, RestoreRowDiffKind, RestoreRowCommitOutcome) into shared-manifests.json and both generated wire-type files; the three backup pages import those and lib/api.ts drops eleven hand-written mirrors. Attribute only: no runtime behaviour changes, no backend deploy needed. Still hand-written in lib/api.ts, with reason: BackupManifest/BackupTableEntry (a blob passed through by GET global/backups/{id}), BackupTriggerResponse (the 202 body of global/backups/trigger is an anonymous object outside the typed regime) and the RestoreRow request body. Co-Authored-By: Claude Fable 5.1 --- .../src/generated/wire-types.generated.ts | 82 ++++ .../Models/Backup/BackupJobStatus.cs | 1 + .../Models/Backup/RestoreRow.cs | 2 + .../components/RestoreRowDiffModal.tsx | 15 +- .../app/admin/backups/detail/page.tsx | 3 +- .../app/admin/backups/page.tsx | 10 +- src/Web/autopilot-monitor-web/lib/api.ts | 69 +--- .../utils/shared-manifests.json | 361 ++++++++++++++++++ .../utils/wire-types.generated.ts | 82 ++++ 9 files changed, 540 insertions(+), 85 deletions(-) diff --git a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts index 2505a89b..2cbc4f86 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts @@ -786,6 +786,41 @@ export interface BackfillSample { decodedUtc: string; } +export type BackupJobKind = "Backup" | "RestoreTable"; + +/** Six-state job machine. Terminal states (Completed / Failed / Skipped / BlockedTerminal) short-circuit the worker's duplicate-detection on reappearance: message is dropped, no re-run. Failed vs BlockedTerminal vs Skipped is intentional so operator-action follow-up can be distinguished (logs / re-trigger / nothing-to-do). */ +export type BackupJobState = "Queued" | "Running" | "Completed" | "Failed" | "Skipped" | "BlockedTerminal"; + +/** Per-job state machine for asynchronous backup + restore work. Persisted as !:BackupJobStatusEntity in BackupJobs (PartitionKey="BackupJobs", RowKey={jobId}). The domain DTO carries strongly-typed enums; the Repository layer maps from/to the string-typed Azure Table columns (Azure Tables has no EDM enum). Unknown strings on read MUST throw — silent fallback is a regression risk after enum refactorings. */ +export interface BackupJobStatus { + jobId: string; + kind: BackupJobKind; + state: BackupJobState; + /** UPN for manual triggers; "Timer" never reaches this DTO — Timer skips the queue. */ + requestedBy: string; + queuedAtUtc: string; + startedAtUtc?: string; + completedAtUtc?: string; + /** Updated by the renewal-loop and per-phase progress writes; drives the watchdog. */ + lastHeartbeatUtc: string; + /** For Kind=Backup: the worker-generated id of the manifest this job produced (null until the worker has acquired the lease and stamped it). UI uses this to deep-link from a completed job to its manifest detail page. */ + backupId?: string; + /** For Kind=RestoreTable: the manifest id the operator is restoring FROM. */ + sourceBackupId?: string; + /** For Kind=RestoreTable: the table being restored. */ + tableName?: string; + /** For Kind=RestoreTable: upsert-only | replace-all. */ + strategy?: string; + /** Free-form JSON for in-flight worker progress (phase, counters). Informational only — recovery does not depend on it. */ + progress?: string; + error?: string; + /** For Kind=Backup with State=Completed: Success | Partial. Null for any other (Kind, State) combination. */ + backupOutcome?: BackupOutcome; +} + +/** Manifest-level outcome. Job-level state (Failed for fatal errors) is tracked separately on BackupJobStatus; a manifest only exists when the run reached the "all tables attempted, manifest write succeeded" milestone. */ +export type BackupOutcome = "Success" | "Partial"; + /** Response of POST devices/block: block/kill acknowledgement. */ export interface BlockDeviceResponse { success: boolean; @@ -3060,6 +3095,53 @@ export interface ReseedTableCountsNode { written: number; } +export type RestoreRowCommitOutcome = "Inserted" | "Replaced"; + +/** Successful response body of mode=commit. Echoes the write outcome. */ +export interface RestoreRowCommitResponse { + backupId: string; + tableName: string; + partitionKey: string; + rowKey: string; + /** Inserted when the live row was absent, Replaced when it existed. */ + outcome: RestoreRowCommitOutcome; +} + +export type RestoreRowDiffKind = "Added" | "Removed" | "Changed" | "Unchanged"; + +/** Response body of mode=preview. Contains the backup-row dump, the live row (or null), a per-property diff, the row-hash to echo on commit, and the live ETag (or null) to echo on commit. */ +export interface RestoreRowPreviewResponse { + backupId: string; + tableName: string; + partitionKey: string; + rowKey: string; + /** EDM-tagged property snapshot from the backup NDJSON line. Keys are case-faithful original Azure Table column names. */ + backupProperties: Record; + /** EDM-tagged property snapshot of the live row, or null if the live row does not currently exist. */ + currentProperties?: Record; + diff: RestoreRowPropertyDiff[]; + /** SHA-256 (hex, lowercase) of the raw NDJSON line bytes. Echo this on Commit. */ + rowSha256: string; + /** Live-row ETag at preview time, or null if the live row did not exist. */ + currentETag?: string; + /** True for tables whose IsEnabled column carries security semantics (AuthTablesFullRestoreForbidden). UI must render the warning banner. */ + isAuthTable: boolean; +} + +/** Per-property diff entry — exactly one property name with a change kind. */ +export interface RestoreRowPropertyDiff { + name: string; + kind: RestoreRowDiffKind; + backup?: RestoreRowPropertySnapshot; + current?: RestoreRowPropertySnapshot; +} + +/** Single EDM-tagged property value carried over the wire. Matches the shape of DeletionPropValue but is a separate DTO so we can serialize JsonElement snapshots from both backup and live rows without leaking a Deletion-namespaced type. */ +export interface RestoreRowPropertySnapshot { + edmType: string; + value: unknown; +} + /** A condition that is evaluated against the event stream */ export interface RuleCondition { /** Descriptive name for this signal (e.g., "proxy_407_error") */ diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Backup/BackupJobStatus.cs b/src/Shared/AutopilotMonitor.Shared/Models/Backup/BackupJobStatus.cs index d6970d7f..05fb71fe 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Backup/BackupJobStatus.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Backup/BackupJobStatus.cs @@ -10,6 +10,7 @@ namespace AutopilotMonitor.Shared.Models.Backup /// EDM enum). Unknown strings on read MUST throw — silent fallback is a regression /// risk after enum refactorings. /// + [WireContract] public sealed class BackupJobStatus { public string JobId { get; set; } = string.Empty; diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Backup/RestoreRow.cs b/src/Shared/AutopilotMonitor.Shared/Models/Backup/RestoreRow.cs index 1e47d9fa..a48113e1 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Backup/RestoreRow.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Backup/RestoreRow.cs @@ -52,6 +52,7 @@ namespace AutopilotMonitor.Shared.Models.Backup /// row (or null), a per-property diff, the row-hash to echo on commit, and the /// live ETag (or null) to echo on commit. /// + [WireContract] public sealed class RestoreRowPreviewResponse { public string BackupId { get; set; } = string.Empty; @@ -91,6 +92,7 @@ namespace AutopilotMonitor.Shared.Models.Backup /// /// Successful response body of mode=commit. Echoes the write outcome. /// + [WireContract] public sealed class RestoreRowCommitResponse { public string BackupId { get; set; } = string.Empty; diff --git a/src/Web/autopilot-monitor-web/app/admin/backups/components/RestoreRowDiffModal.tsx b/src/Web/autopilot-monitor-web/app/admin/backups/components/RestoreRowDiffModal.tsx index 38a8911b..cfbbfe05 100644 --- a/src/Web/autopilot-monitor-web/app/admin/backups/components/RestoreRowDiffModal.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/backups/components/RestoreRowDiffModal.tsx @@ -1,14 +1,13 @@ "use client"; import { useState } from "react"; -import { - api, - type RestoreRowCommitResponse, - type RestoreRowPreviewResponse, - type RestoreRowPropertyDiff, - type RestoreRowPropertySnapshot, - type RestoreRowRequestBody, -} from "@/lib/api"; +import { api, type RestoreRowRequestBody } from "@/lib/api"; +import type { + RestoreRowCommitResponse, + RestoreRowPreviewResponse, + RestoreRowPropertyDiff, + RestoreRowPropertySnapshot, +} from "@/utils/wire-types.generated"; import { apiErrorText, fetchJson } from "@/lib/apiClient"; interface RestoreRowDiffModalProps { diff --git a/src/Web/autopilot-monitor-web/app/admin/backups/detail/page.tsx b/src/Web/autopilot-monitor-web/app/admin/backups/detail/page.tsx index c85ba415..872668f8 100644 --- a/src/Web/autopilot-monitor-web/app/admin/backups/detail/page.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/backups/detail/page.tsx @@ -7,10 +7,9 @@ import { api, type BackupManifest, type BackupTableEntry, - type RestoreRowCommitResponse, - type RestoreRowPreviewResponse, type RestoreRowRequestBody, } from "@/lib/api"; +import type { RestoreRowCommitResponse, RestoreRowPreviewResponse } from "@/utils/wire-types.generated"; import { apiErrorText, fetchJson } from "@/lib/apiClient"; import { useAdminConfig } from "../../AdminConfigContext"; import { AdminNotifications } from "../../AdminNotifications"; diff --git a/src/Web/autopilot-monitor-web/app/admin/backups/page.tsx b/src/Web/autopilot-monitor-web/app/admin/backups/page.tsx index 34eaeaf6..b3616fe1 100644 --- a/src/Web/autopilot-monitor-web/app/admin/backups/page.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/backups/page.tsx @@ -3,12 +3,8 @@ import { backupUrl } from "@/lib/routes"; import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; -import { - api, - type BackupJobStatus, - type BackupListResponse, - type BackupTriggerResponse, -} from "@/lib/api"; +import { api, type BackupTriggerResponse } from "@/lib/api"; +import type { BackupJobStatus, ListBackupsResponse } from "@/utils/wire-types.generated"; import { apiErrorText, fetchJson } from "@/lib/apiClient"; import { useAdminConfig } from "../AdminConfigContext"; import { AdminNotifications } from "../AdminNotifications"; @@ -31,7 +27,7 @@ export default function BackupsListPage() { const load = useCallback(async () => { try { setLoading(true); - const body = await fetchJson(api.backups.list(), getAccessToken); + const body = await fetchJson(api.backups.list(), getAccessToken); setBackupIds(body.backupIds ?? []); } catch (err) { setError(apiErrorText(err)); diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts index cbe549f0..1d4c9a32 100644 --- a/src/Web/autopilot-monitor-web/lib/api.ts +++ b/src/Web/autopilot-monitor-web/lib/api.ts @@ -3,6 +3,7 @@ * All backend endpoint URLs are defined here for type-safety and maintainability. */ import { API_BASE_URL } from "@/utils/config"; +import type { BackupOutcome } from "@/utils/wire-types.generated"; function qs(params: Record): string { const p = new URLSearchParams(); @@ -786,12 +787,7 @@ export const api = { // ── Backups response types (PR1 + PR2) ──────────────────────────────────── -export interface BackupListResponse { - backupIds: string[]; -} - export type BackupTableStatus = "Ok" | "Empty" | "Skipped" | "Failed"; -export type BackupOutcome = "Success" | "Partial"; export interface BackupTableEntry { tableName: string; @@ -813,33 +809,6 @@ export interface BackupManifest { tables: BackupTableEntry[]; } -export type BackupJobKind = "Backup" | "RestoreTable"; -export type BackupJobState = - | "Queued" - | "Running" - | "Completed" - | "Failed" - | "Skipped" - | "BlockedTerminal"; - -export interface BackupJobStatus { - jobId: string; - kind: BackupJobKind; - state: BackupJobState; - requestedBy: string; - queuedAtUtc: string; - startedAtUtc?: string | null; - completedAtUtc?: string | null; - lastHeartbeatUtc: string; - backupId?: string | null; - sourceBackupId?: string | null; - tableName?: string | null; - strategy?: string | null; - progress?: string | null; - error?: string | null; - backupOutcome?: BackupOutcome | null; -} - export interface BackupTriggerResponse { jobId: string; statusUrl: string; @@ -848,42 +817,6 @@ export interface BackupTriggerResponse { // ── PR2: single-row restore ─────────────────────────────────────────────── export type RestoreRowMode = "Preview" | "Commit"; -export type RestoreRowDiffKind = "Added" | "Removed" | "Changed" | "Unchanged"; -export type RestoreRowCommitOutcome = "Inserted" | "Replaced"; - -export interface RestoreRowPropertySnapshot { - edmType: string; - // JsonElement on the wire — string | number | boolean | null | object | array - value: unknown; -} - -export interface RestoreRowPropertyDiff { - name: string; - kind: RestoreRowDiffKind; - backup?: RestoreRowPropertySnapshot | null; - current?: RestoreRowPropertySnapshot | null; -} - -export interface RestoreRowPreviewResponse { - backupId: string; - tableName: string; - partitionKey: string; - rowKey: string; - backupProperties: Record; - currentProperties?: Record | null; - diff: RestoreRowPropertyDiff[]; - rowSha256: string; - currentETag?: string | null; - isAuthTable: boolean; -} - -export interface RestoreRowCommitResponse { - backupId: string; - tableName: string; - partitionKey: string; - rowKey: string; - outcome: RestoreRowCommitOutcome; -} export interface RestoreRowRequestBody { tableName: string; diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.json b/src/Web/autopilot-monitor-web/utils/shared-manifests.json index 14474e12..85ef903f 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.json +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.json @@ -4084,6 +4084,161 @@ } ] }, + "BackupJobKind": { + "kind": "enum", + "members": [ + "Backup", + "RestoreTable" + ] + }, + "BackupJobState": { + "kind": "enum", + "doc": "Six-state job machine. Terminal states (Completed / Failed / Skipped / BlockedTerminal) short-circuit the worker's duplicate-detection on reappearance: message is dropped, no re-run. Failed vs BlockedTerminal vs Skipped is intentional so operator-action follow-up can be distinguished (logs / re-trigger / nothing-to-do).", + "members": [ + "Queued", + "Running", + "Completed", + "Failed", + "Skipped", + "BlockedTerminal" + ] + }, + "BackupJobStatus": { + "kind": "object", + "doc": "Per-job state machine for asynchronous backup + restore work. Persisted as !:BackupJobStatusEntity in BackupJobs (PartitionKey=\"BackupJobs\", RowKey={jobId}). The domain DTO carries strongly-typed enums; the Repository layer maps from/to the string-typed Azure Table columns (Azure Tables has no EDM enum). Unknown strings on read MUST throw — silent fallback is a regression risk after enum refactorings.", + "fields": [ + { + "name": "jobId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "kind", + "type": { + "kind": "enum", + "name": "BackupJobKind" + } + }, + { + "name": "state", + "type": { + "kind": "enum", + "name": "BackupJobState" + } + }, + { + "name": "requestedBy", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "UPN for manual triggers; \"Timer\" never reaches this DTO — Timer skips the queue." + }, + { + "name": "queuedAtUtc", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "startedAtUtc", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, + { + "name": "completedAtUtc", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, + { + "name": "lastHeartbeatUtc", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "Updated by the renewal-loop and per-phase progress writes; drives the watchdog." + }, + { + "name": "backupId", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "For Kind=Backup: the worker-generated id of the manifest this job produced (null until the worker has acquired the lease and stamped it). UI uses this to deep-link from a completed job to its manifest detail page." + }, + { + "name": "sourceBackupId", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "For Kind=RestoreTable: the manifest id the operator is restoring FROM." + }, + { + "name": "tableName", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "For Kind=RestoreTable: the table being restored." + }, + { + "name": "strategy", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "For Kind=RestoreTable: upsert-only | replace-all." + }, + { + "name": "progress", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "Free-form JSON for in-flight worker progress (phase, counters). Informational only — recovery does not depend on it." + }, + { + "name": "error", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true + }, + { + "name": "backupOutcome", + "type": { + "kind": "enum", + "name": "BackupOutcome" + }, + "optional": true, + "doc": "For Kind=Backup with State=Completed: Success | Partial. Null for any other (Kind, State) combination." + } + ] + }, + "BackupOutcome": { + "kind": "enum", + "doc": "Manifest-level outcome. Job-level state (Failed for fatal errors) is tracked separately on BackupJobStatus; a manifest only exists when the run reached the \"all tables attempted, manifest write succeeded\" milestone.", + "members": [ + "Success", + "Partial" + ] + }, "BlockDeviceResponse": { "kind": "object", "doc": "Response of POST devices/block: block/kill acknowledgement.", @@ -14142,6 +14297,212 @@ } ] }, + "RestoreRowCommitOutcome": { + "kind": "enum", + "members": [ + "Inserted", + "Replaced" + ] + }, + "RestoreRowCommitResponse": { + "kind": "object", + "doc": "Successful response body of mode=commit. Echoes the write outcome.", + "fields": [ + { + "name": "backupId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "tableName", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "partitionKey", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "rowKey", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "outcome", + "type": { + "kind": "enum", + "name": "RestoreRowCommitOutcome" + }, + "doc": "Inserted when the live row was absent, Replaced when it existed." + } + ] + }, + "RestoreRowDiffKind": { + "kind": "enum", + "members": [ + "Added", + "Removed", + "Changed", + "Unchanged" + ] + }, + "RestoreRowPreviewResponse": { + "kind": "object", + "doc": "Response body of mode=preview. Contains the backup-row dump, the live row (or null), a per-property diff, the row-hash to echo on commit, and the live ETag (or null) to echo on commit.", + "fields": [ + { + "name": "backupId", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "tableName", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "partitionKey", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "rowKey", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "backupProperties", + "type": { + "kind": "map", + "value": { + "kind": "ref", + "name": "RestoreRowPropertySnapshot" + } + }, + "doc": "EDM-tagged property snapshot from the backup NDJSON line. Keys are case-faithful original Azure Table column names." + }, + { + "name": "currentProperties", + "type": { + "kind": "map", + "value": { + "kind": "ref", + "name": "RestoreRowPropertySnapshot" + } + }, + "optional": true, + "doc": "EDM-tagged property snapshot of the live row, or null if the live row does not currently exist." + }, + { + "name": "diff", + "type": { + "kind": "array", + "element": { + "kind": "ref", + "name": "RestoreRowPropertyDiff" + } + } + }, + { + "name": "rowSha256", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "SHA-256 (hex, lowercase) of the raw NDJSON line bytes. Echo this on Commit." + }, + { + "name": "currentETag", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "Live-row ETag at preview time, or null if the live row did not exist." + }, + { + "name": "isAuthTable", + "type": { + "kind": "primitive", + "name": "boolean" + }, + "doc": "True for tables whose IsEnabled column carries security semantics (AuthTablesFullRestoreForbidden). UI must render the warning banner." + } + ] + }, + "RestoreRowPropertyDiff": { + "kind": "object", + "doc": "Per-property diff entry — exactly one property name with a change kind.", + "fields": [ + { + "name": "name", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "kind", + "type": { + "kind": "enum", + "name": "RestoreRowDiffKind" + } + }, + { + "name": "backup", + "type": { + "kind": "ref", + "name": "RestoreRowPropertySnapshot" + }, + "optional": true + }, + { + "name": "current", + "type": { + "kind": "ref", + "name": "RestoreRowPropertySnapshot" + }, + "optional": true + } + ] + }, + "RestoreRowPropertySnapshot": { + "kind": "object", + "doc": "Single EDM-tagged property value carried over the wire. Matches the shape of DeletionPropValue but is a separate DTO so we can serialize JsonElement snapshots from both backup and live rows without leaking a Deletion-namespaced type.", + "fields": [ + { + "name": "edmType", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "value", + "type": { + "kind": "primitive", + "name": "unknown" + } + } + ] + }, "RuleCondition": { "kind": "object", "doc": "A condition that is evaluated against the event stream", diff --git a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts index 04bd69f7..e37b094c 100644 --- a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts @@ -786,6 +786,41 @@ export interface BackfillSample { decodedUtc: string; } +export type BackupJobKind = "Backup" | "RestoreTable"; + +/** Six-state job machine. Terminal states (Completed / Failed / Skipped / BlockedTerminal) short-circuit the worker's duplicate-detection on reappearance: message is dropped, no re-run. Failed vs BlockedTerminal vs Skipped is intentional so operator-action follow-up can be distinguished (logs / re-trigger / nothing-to-do). */ +export type BackupJobState = "Queued" | "Running" | "Completed" | "Failed" | "Skipped" | "BlockedTerminal"; + +/** Per-job state machine for asynchronous backup + restore work. Persisted as !:BackupJobStatusEntity in BackupJobs (PartitionKey="BackupJobs", RowKey={jobId}). The domain DTO carries strongly-typed enums; the Repository layer maps from/to the string-typed Azure Table columns (Azure Tables has no EDM enum). Unknown strings on read MUST throw — silent fallback is a regression risk after enum refactorings. */ +export interface BackupJobStatus { + jobId: string; + kind: BackupJobKind; + state: BackupJobState; + /** UPN for manual triggers; "Timer" never reaches this DTO — Timer skips the queue. */ + requestedBy: string; + queuedAtUtc: string; + startedAtUtc?: string; + completedAtUtc?: string; + /** Updated by the renewal-loop and per-phase progress writes; drives the watchdog. */ + lastHeartbeatUtc: string; + /** For Kind=Backup: the worker-generated id of the manifest this job produced (null until the worker has acquired the lease and stamped it). UI uses this to deep-link from a completed job to its manifest detail page. */ + backupId?: string; + /** For Kind=RestoreTable: the manifest id the operator is restoring FROM. */ + sourceBackupId?: string; + /** For Kind=RestoreTable: the table being restored. */ + tableName?: string; + /** For Kind=RestoreTable: upsert-only | replace-all. */ + strategy?: string; + /** Free-form JSON for in-flight worker progress (phase, counters). Informational only — recovery does not depend on it. */ + progress?: string; + error?: string; + /** For Kind=Backup with State=Completed: Success | Partial. Null for any other (Kind, State) combination. */ + backupOutcome?: BackupOutcome; +} + +/** Manifest-level outcome. Job-level state (Failed for fatal errors) is tracked separately on BackupJobStatus; a manifest only exists when the run reached the "all tables attempted, manifest write succeeded" milestone. */ +export type BackupOutcome = "Success" | "Partial"; + /** Response of POST devices/block: block/kill acknowledgement. */ export interface BlockDeviceResponse { success: boolean; @@ -3060,6 +3095,53 @@ export interface ReseedTableCountsNode { written: number; } +export type RestoreRowCommitOutcome = "Inserted" | "Replaced"; + +/** Successful response body of mode=commit. Echoes the write outcome. */ +export interface RestoreRowCommitResponse { + backupId: string; + tableName: string; + partitionKey: string; + rowKey: string; + /** Inserted when the live row was absent, Replaced when it existed. */ + outcome: RestoreRowCommitOutcome; +} + +export type RestoreRowDiffKind = "Added" | "Removed" | "Changed" | "Unchanged"; + +/** Response body of mode=preview. Contains the backup-row dump, the live row (or null), a per-property diff, the row-hash to echo on commit, and the live ETag (or null) to echo on commit. */ +export interface RestoreRowPreviewResponse { + backupId: string; + tableName: string; + partitionKey: string; + rowKey: string; + /** EDM-tagged property snapshot from the backup NDJSON line. Keys are case-faithful original Azure Table column names. */ + backupProperties: Record; + /** EDM-tagged property snapshot of the live row, or null if the live row does not currently exist. */ + currentProperties?: Record; + diff: RestoreRowPropertyDiff[]; + /** SHA-256 (hex, lowercase) of the raw NDJSON line bytes. Echo this on Commit. */ + rowSha256: string; + /** Live-row ETag at preview time, or null if the live row did not exist. */ + currentETag?: string; + /** True for tables whose IsEnabled column carries security semantics (AuthTablesFullRestoreForbidden). UI must render the warning banner. */ + isAuthTable: boolean; +} + +/** Per-property diff entry — exactly one property name with a change kind. */ +export interface RestoreRowPropertyDiff { + name: string; + kind: RestoreRowDiffKind; + backup?: RestoreRowPropertySnapshot; + current?: RestoreRowPropertySnapshot; +} + +/** Single EDM-tagged property value carried over the wire. Matches the shape of DeletionPropValue but is a separate DTO so we can serialize JsonElement snapshots from both backup and live rows without leaking a Deletion-namespaced type. */ +export interface RestoreRowPropertySnapshot { + edmType: string; + value: unknown; +} + /** A condition that is evaluated against the event stream */ export interface RuleCondition { /** Descriptive name for this signal (e.g., "proxy_407_error") */