feat(desktop): add mutation review guardrails

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Senior Frontend Engineer
2026-03-31 08:47:14 +00:00
parent bf6f8bc0bf
commit 35040ef0c9
7 changed files with 1084 additions and 125 deletions

View File

@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import {
buildMutationGuardDialogContent,
canRemoveTtl,
hasMeaningfulStringChange,
hasMeaningfulTtlChange,
} from "./mutation-guardrails";
describe("mutation guardrails", () => {
it("summarizes live string replacement with current and next payload previews", () => {
expect(
buildMutationGuardDialogContent({
kind: "value_write",
runtimeMode: "live",
keyName: "cache:homepage",
databaseLabel: "db0",
currentValue: "hello",
nextValue: "hello world",
}),
).toEqual({
title: "Replace string value for cache:homepage?",
description: "Review the scoped string save for db0 before continuing.",
currentLabel: "Current value",
currentValue: '5 chars, 1 line, preview "hello"',
nextLabel: "Next value",
nextValue: '11 chars, 1 line, preview "hello world"',
warning: "This replaces the full live string value and keeps the current TTL boundary.",
confirmLabel: "Save live string",
});
});
it("summarizes TTL update in browser demo mode", () => {
expect(
buildMutationGuardDialogContent({
kind: "ttl_update",
runtimeMode: "demo",
keyName: "session:42",
databaseLabel: "db5",
currentTtl: "persistent",
nextTtl: "1m",
}),
).toEqual({
title: "Apply TTL to session:42?",
description: "Review the scoped TTL change for db5 before continuing.",
currentLabel: "Current TTL",
currentValue: "persistent",
nextLabel: "Next TTL",
nextValue: "1m",
warning: "This only updates the session-local browser demo state.",
confirmLabel: "Set demo TTL",
});
});
it("summarizes live TTL removal", () => {
expect(
buildMutationGuardDialogContent({
kind: "ttl_remove",
runtimeMode: "live",
keyName: "smoke:string:ttl",
databaseLabel: "db0",
currentTtl: "48s",
}),
).toEqual({
title: "Remove TTL from smoke:string:ttl?",
description: "Review the scoped TTL removal for db0 before continuing.",
currentLabel: "Current TTL",
currentValue: "48s",
nextLabel: "Next TTL",
nextValue: "persistent",
warning: "This removes the live expiry and makes the key persistent.",
confirmLabel: "Remove live TTL",
});
});
it("detects no-op string saves", () => {
expect(hasMeaningfulStringChange("hello", "hello")).toBe(false);
expect(hasMeaningfulStringChange("hello", "hello world")).toBe(true);
});
it("detects exact live TTL no-ops and formatted demo TTL no-ops", () => {
expect(
hasMeaningfulTtlChange({ kind: "expires_in_millis", value: 60_000 }, 60_000),
).toBe(false);
expect(hasMeaningfulTtlChange({ kind: "display", value: "1m" }, 60_000)).toBe(false);
expect(hasMeaningfulTtlChange({ kind: "display", value: "1m" }, 120_000)).toBe(true);
});
it("blocks TTL removal when the key is already persistent", () => {
expect(canRemoveTtl({ kind: "persistent" })).toBe(false);
expect(canRemoveTtl({ kind: "display", value: "persistent" })).toBe(false);
expect(canRemoveTtl({ kind: "expires_in_millis", value: 10_000 })).toBe(true);
});
});

View File

@@ -0,0 +1,178 @@
export type MutationGuardRuntimeMode = "live" | "demo";
export type ComparableTtlState =
| { kind: "persistent" }
| { kind: "missing" }
| { kind: "expires_in_millis"; value: number }
| { kind: "display"; value: string };
export type MutationGuardDialogInput =
| {
kind: "value_write";
runtimeMode: MutationGuardRuntimeMode;
keyName: string;
databaseLabel: string;
currentValue: string;
nextValue: string;
}
| {
kind: "ttl_update";
runtimeMode: MutationGuardRuntimeMode;
keyName: string;
databaseLabel: string;
currentTtl: string;
nextTtl: string;
}
| {
kind: "ttl_remove";
runtimeMode: MutationGuardRuntimeMode;
keyName: string;
databaseLabel: string;
currentTtl: string;
};
export type MutationGuardDialogContent = {
title: string;
description: string;
currentLabel: string;
currentValue: string;
nextLabel: string;
nextValue: string;
warning: string;
confirmLabel: string;
};
export function buildMutationGuardDialogContent(
input: MutationGuardDialogInput,
): MutationGuardDialogContent {
switch (input.kind) {
case "value_write":
return {
title: `Replace string value for ${input.keyName}?`,
description: `Review the scoped string save for ${input.databaseLabel} before continuing.`,
currentLabel: "Current value",
currentValue: describeStringPayload(input.currentValue),
nextLabel: "Next value",
nextValue: describeStringPayload(input.nextValue),
warning:
input.runtimeMode === "live"
? "This replaces the full live string value and keeps the current TTL boundary."
: "This only updates the session-local browser demo state.",
confirmLabel:
input.runtimeMode === "live" ? "Save live string" : "Save demo string",
};
case "ttl_update":
return {
title: `Apply TTL to ${input.keyName}?`,
description: `Review the scoped TTL change for ${input.databaseLabel} before continuing.`,
currentLabel: "Current TTL",
currentValue: input.currentTtl,
nextLabel: "Next TTL",
nextValue: input.nextTtl,
warning:
input.runtimeMode === "live"
? "This changes the live expiry for the selected key in Redis."
: "This only updates the session-local browser demo state.",
confirmLabel: input.runtimeMode === "live" ? "Set live TTL" : "Set demo TTL",
};
case "ttl_remove":
return {
title: `Remove TTL from ${input.keyName}?`,
description: `Review the scoped TTL removal for ${input.databaseLabel} before continuing.`,
currentLabel: "Current TTL",
currentValue: input.currentTtl,
nextLabel: "Next TTL",
nextValue: "persistent",
warning:
input.runtimeMode === "live"
? "This removes the live expiry and makes the key persistent."
: "This only updates the session-local browser demo state.",
confirmLabel:
input.runtimeMode === "live" ? "Remove live TTL" : "Remove demo TTL",
};
default:
return {
title: "Review mutation",
description: "Review the scoped mutation before continuing.",
currentLabel: "Current",
currentValue: "n/a",
nextLabel: "Next",
nextValue: "n/a",
warning: "",
confirmLabel: "Confirm",
};
}
}
export function hasMeaningfulStringChange(currentValue: string, nextValue: string) {
return currentValue !== nextValue;
}
export function hasMeaningfulTtlChange(
currentTtl: ComparableTtlState,
nextTtlMillis: number,
) {
switch (currentTtl.kind) {
case "expires_in_millis":
return currentTtl.value !== nextTtlMillis;
case "display":
return currentTtl.value !== formatDurationFromMillis(nextTtlMillis);
case "persistent":
case "missing":
default:
return true;
}
}
export function canRemoveTtl(currentTtl: ComparableTtlState) {
switch (currentTtl.kind) {
case "persistent":
return false;
case "display":
return currentTtl.value !== "persistent";
case "expires_in_millis":
case "missing":
default:
return true;
}
}
function describeStringPayload(value: string) {
const lines = value.split(/\r?\n/).length;
const compactPreview = value.replace(/\s+/g, " ").trim();
const preview = compactPreview ? truncateText(compactPreview, 56) : "(empty string)";
return `${value.length} chars, ${lines} line${lines === 1 ? "" : "s"}, preview ${JSON.stringify(preview)}`;
}
function truncateText(value: string, maxLength: number) {
if (value.length <= maxLength) {
return value;
}
return `${value.slice(0, maxLength - 3)}...`;
}
function formatDurationFromMillis(value: number) {
if (value < 1_000) {
return `${value}ms`;
}
const totalSeconds = Math.floor(value / 1_000);
if (totalSeconds < 60) {
return `${totalSeconds}s`;
}
const totalMinutes = Math.floor(totalSeconds / 60);
if (totalMinutes < 60) {
return `${totalMinutes}m`;
}
const totalHours = Math.floor(totalMinutes / 60);
if (totalHours < 24) {
return `${totalHours}h`;
}
const totalDays = Math.floor(totalHours / 24);
return `${totalDays}d`;
}