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 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-09-06 02:34:11 +02:00
parent e71223507c
commit 298a182fbb
9 changed files with 540 additions and 85 deletions
@@ -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<string, RestoreRowPropertySnapshot>;
/** EDM-tagged property snapshot of the live row, or null if the live row does not currently exist. */
currentProperties?: Record<string, RestoreRowPropertySnapshot>;
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") */
@@ -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.
/// </summary>
[WireContract]
public sealed class BackupJobStatus
{
public string JobId { get; set; } = string.Empty;
@@ -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.
/// </summary>
[WireContract]
public sealed class RestoreRowPreviewResponse
{
public string BackupId { get; set; } = string.Empty;
@@ -91,6 +92,7 @@ namespace AutopilotMonitor.Shared.Models.Backup
/// <summary>
/// Successful response body of <c>mode=commit</c>. Echoes the write outcome.
/// </summary>
[WireContract]
public sealed class RestoreRowCommitResponse
{
public string BackupId { get; set; } = string.Empty;
@@ -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 {
@@ -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";
@@ -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<BackupListResponse>(api.backups.list(), getAccessToken);
const body = await fetchJson<ListBackupsResponse>(api.backups.list(), getAccessToken);
setBackupIds(body.backupIds ?? []);
} catch (err) {
setError(apiErrorText(err));
+1 -68
View File
@@ -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, string | undefined>): 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<string, RestoreRowPropertySnapshot>;
currentProperties?: Record<string, RestoreRowPropertySnapshot> | 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;
@@ -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",
@@ -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<string, RestoreRowPropertySnapshot>;
/** EDM-tagged property snapshot of the live row, or null if the live row does not currently exist. */
currentProperties?: Record<string, RestoreRowPropertySnapshot>;
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") */