feat(desktop): add mutation review guardrails
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
93
apps/desktop/src/lib/mutation-guardrails.test.ts
Normal file
93
apps/desktop/src/lib/mutation-guardrails.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
178
apps/desktop/src/lib/mutation-guardrails.ts
Normal file
178
apps/desktop/src/lib/mutation-guardrails.ts
Normal 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`;
|
||||
}
|
||||
Reference in New Issue
Block a user