feat(desktop): add observer-frame retention control and archive size readout

The local archive Settings card exposed archive subscriptions but no way to
configure how long observer frames (kind 24200) are retained, and no visibility
into how large the archive DB has grown. The retention backend (#5719) shipped
the get/set/size commands but nothing consumed them.

Adds a retention field bound to 1..=36500 days (validated client-side to match
the fail-closed backend) with copy stating metrics and every other archived
kind are kept forever, plus a size readout showing physical bytes (main + WAL)
and reclaimable freelist bytes — labeled so it never implies the file has
already shrunk (freed pages only return space after an offline VACUUM).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Hayt
2026-08-21 14:27:57 -04:00
parent aeb741fd31
commit cc8da01d37
5 changed files with 625 additions and 0 deletions

View File

@@ -0,0 +1,122 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
MAX_RETENTION_DAYS,
MIN_RETENTION_DAYS,
deriveSizeReadout,
formatBytes,
parseRetentionDays,
} from "./retentionSettings.ts";
// ── parseRetentionDays ────────────────────────────────────────────────────────
test("parseRetentionDays_validInteger_ok", () => {
assert.deepEqual(parseRetentionDays("30"), { ok: true, days: 30 });
});
test("parseRetentionDays_trimsWhitespace", () => {
assert.deepEqual(parseRetentionDays(" 7 "), { ok: true, days: 7 });
});
test("parseRetentionDays_min_ok", () => {
assert.deepEqual(parseRetentionDays(String(MIN_RETENTION_DAYS)), {
ok: true,
days: MIN_RETENTION_DAYS,
});
});
test("parseRetentionDays_max_ok", () => {
assert.deepEqual(parseRetentionDays(String(MAX_RETENTION_DAYS)), {
ok: true,
days: MAX_RETENTION_DAYS,
});
});
test("parseRetentionDays_empty_error", () => {
const r = parseRetentionDays(" ");
assert.equal(r.ok, false);
});
test("parseRetentionDays_zero_belowMin", () => {
const r = parseRetentionDays("0");
assert.equal(r.ok, false);
assert.match(r.error, /at least 1/);
});
test("parseRetentionDays_negative_rejected", () => {
// The leading "-" fails the digits-only shape before the range check.
const r = parseRetentionDays("-5");
assert.equal(r.ok, false);
assert.match(r.error, /whole number/);
});
test("parseRetentionDays_fractional_rejected", () => {
const r = parseRetentionDays("3.5");
assert.equal(r.ok, false);
assert.match(r.error, /whole number/);
});
test("parseRetentionDays_nonNumeric_rejected", () => {
const r = parseRetentionDays("abc");
assert.equal(r.ok, false);
});
test("parseRetentionDays_aboveMax_rejected", () => {
const r = parseRetentionDays(String(MAX_RETENTION_DAYS + 1));
assert.equal(r.ok, false);
assert.match(r.error, /at most/);
});
// ── formatBytes ───────────────────────────────────────────────────────────────
test("formatBytes_zeroAndNegative_clampToZero", () => {
assert.equal(formatBytes(0), "0 B");
assert.equal(formatBytes(-100), "0 B");
assert.equal(formatBytes(Number.NaN), "0 B");
});
test("formatBytes_bytes", () => {
assert.equal(formatBytes(820), "820 B");
});
test("formatBytes_kilobytes_oneDecimalUnderTen", () => {
assert.equal(formatBytes(1024 * 3 + 410), "3.4 KB");
});
test("formatBytes_gigabytes", () => {
assert.equal(formatBytes(7.4 * 1024 ** 3), "7.4 GB");
});
test("formatBytes_roundsAtTenOrAbove", () => {
// 12.4 GB rounds to whole number once ≥ 10 in its unit.
assert.equal(formatBytes(12.4 * 1024 ** 3), "12 GB");
});
// ── deriveSizeReadout ─────────────────────────────────────────────────────────
test("deriveSizeReadout_physicalIsMainPlusWal", () => {
const r = deriveSizeReadout({
mainFileBytes: 1024 ** 3,
walFileBytes: 512 * 1024 ** 2,
pageSize: 4096,
pageCount: 1000,
freelistCount: 0,
});
assert.equal(r.physicalBytes, 1024 ** 3 + 512 * 1024 ** 2);
assert.equal(r.physicalLabel, "1.5 GB");
assert.equal(r.reclaimableBytes, 0);
assert.equal(r.reclaimableLabel, "0 B");
});
test("deriveSizeReadout_reclaimableIsFreelistTimesPageSize", () => {
const r = deriveSizeReadout({
mainFileBytes: 0,
walFileBytes: 0,
pageSize: 4096,
freelistCount: 262_144, // 256K pages × 4KB = 1 GB
pageCount: 300_000,
});
assert.equal(r.reclaimableBytes, 1024 ** 3);
assert.equal(r.reclaimableLabel, "1.0 GB");
});

View File

@@ -0,0 +1,85 @@
// Pure helpers for the observer-frame retention control and the archive size
// readout. Kept free of React and Tauri so the branch-heavy logic — input
// parsing, client-side bound validation, and byte formatting — is unit-tested
// directly, matching the `localArchiveKinds` split in this feature.
import type { ArchiveSizeStats } from "@/shared/api/tauriArchive";
/** Inclusive bounds mirroring the backend's `validate_days` (`retention.rs`). */
export const MIN_RETENTION_DAYS = 1;
export const MAX_RETENTION_DAYS = 36_500;
export type ParsedDays =
| { ok: true; days: number }
| { ok: false; error: string };
/**
* Parse and bound-check a retention-days text input. Accepts only a plain
* non-negative integer in `[MIN, MAX]`; anything else (blank, non-numeric,
* fractional, signed, out-of-range) is rejected with a message suitable for
* inline display. Mirrors the backend contract so the invoke is only issued
* for values the store will accept.
*/
export function parseRetentionDays(raw: string): ParsedDays {
const trimmed = raw.trim();
if (trimmed === "") return { ok: false, error: "Enter a number of days." };
// Reject signs, decimals, whitespace-in-the-middle — only bare digits.
if (!/^\d+$/.test(trimmed)) {
return { ok: false, error: "Days must be a whole number." };
}
const days = Number(trimmed);
if (days < MIN_RETENTION_DAYS) {
return { ok: false, error: `Days must be at least ${MIN_RETENTION_DAYS}.` };
}
if (days > MAX_RETENTION_DAYS) {
return {
ok: false,
error: `Days must be at most ${MAX_RETENTION_DAYS.toLocaleString()}.`,
};
}
return { ok: true, days };
}
/**
* Human-readable byte size: "0 B", "820 B", "12.4 KB", "3.1 MB", "7.4 GB".
* Binary (1024) units, matching `FileCard`'s `formatFileSize`. Negative or
* non-finite inputs clamp to "0 B" rather than rendering nonsense.
*/
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
if (bytes < 1024) return `${Math.round(bytes)} B`;
const units = ["KB", "MB", "GB", "TB"];
let size = bytes / 1024;
let i = 0;
while (size >= 1024 && i < units.length - 1) {
size /= 1024;
i += 1;
}
return `${size < 10 ? size.toFixed(1) : Math.round(size)} ${units[i]}`;
}
export type ArchiveSizeReadout = {
/** Physical bytes actually on disk: main DB file + `-wal` sidecar. */
physicalBytes: number;
/** Reclaimable bytes sitting on the freelist (`freelistCount × pageSize`). */
reclaimableBytes: number;
physicalLabel: string;
reclaimableLabel: string;
};
/**
* Derive the size readout from raw stats. Physical size is the honest on-disk
* footprint (main + WAL); reclaimable is freelist pages, which only shrink the
* file after an offline VACUUM — until then they are savings, not a smaller
* file. Callers label them so the UI never implies the file has already shrunk.
*/
export function deriveSizeReadout(stats: ArchiveSizeStats): ArchiveSizeReadout {
const physicalBytes = stats.mainFileBytes + stats.walFileBytes;
const reclaimableBytes = Math.max(0, stats.freelistCount * stats.pageSize);
return {
physicalBytes,
reclaimableBytes,
physicalLabel: formatBytes(physicalBytes),
reclaimableLabel: formatBytes(reclaimableBytes),
};
}

View File

@@ -0,0 +1,209 @@
/**
* Mounted-render tests for the two archive-retention sections added in P3':
* ObserverRetentionSection and ArchiveSizeSection.
*
* These mount the REAL subcomponents (exported from LocalArchiveSettingsCard)
* against a mocked Tauri IPC bridge, so the render → validate → invoke path is
* exercised end-to-end. The full card is not mounted: it depends on the
* communities/channels query context, which is orthogonal to what these
* sections do. The bound-parsing and byte-formatting logic they build on is
* unit-tested directly in `retentionSettings.test.mjs`.
*/
import assert from "node:assert/strict";
import { after, afterEach, before, test } from "node:test";
import { JSDOM } from "jsdom";
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
const ipcHandlers = new Map();
let act;
let cleanup;
let fireEvent;
let render;
let screen;
let createElement;
let ObserverRetentionSection;
let ArchiveSizeSection;
before(async () => {
Object.assign(globalThis, {
document: dom.window.document,
HTMLElement: dom.window.HTMLElement,
window: dom.window,
IS_REACT_ACT_ENVIRONMENT: true,
});
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: dom.window.navigator,
writable: true,
});
dom.window.matchMedia = () => ({
matches: false,
addEventListener() {},
removeEventListener() {},
});
dom.window.__TAURI_INTERNALS__ = {
invoke: (cmd, args) => {
const handler = ipcHandlers.get(cmd);
if (handler) return handler(args);
return Promise.reject(new Error(`unmocked Tauri command: ${cmd}`));
},
transformCallback: () => Math.random(),
};
({ act, cleanup, fireEvent, render, screen } = await import(
"@testing-library/react"
));
({ createElement } = await import("react"));
({ ObserverRetentionSection, ArchiveSizeSection } = await import(
"./LocalArchiveSettingsCard.tsx"
));
});
afterEach(() => {
cleanup?.();
ipcHandlers.clear();
});
after(() => dom.window.close());
// ── ObserverRetentionSection ──────────────────────────────────────────────────
test("retention field seeds from the saved value and disables Save while unchanged", async () => {
await act(async () => {
render(
createElement(ObserverRetentionSection, {
savedDays: 30,
onSaved: () => {},
}),
);
});
const input = screen.getByTestId("local-archive-retention-days");
assert.equal(input.value, "30");
const save = screen.getByTestId("local-archive-retention-save");
assert.equal(save.disabled, true, "unchanged value cannot be saved");
});
test("input disabled until the saved value loads", async () => {
await act(async () => {
render(
createElement(ObserverRetentionSection, {
savedDays: null,
onSaved: () => {},
}),
);
});
assert.equal(
screen.getByTestId("local-archive-retention-days").disabled,
true,
);
});
test("out-of-bounds input surfaces an inline error and blocks Save", async () => {
await act(async () => {
render(
createElement(ObserverRetentionSection, {
savedDays: 30,
onSaved: () => {},
}),
);
});
const input = screen.getByTestId("local-archive-retention-days");
await act(async () => {
fireEvent.change(input, { target: { value: "0" } });
});
assert.match(
screen.getByTestId("local-archive-retention-error").textContent,
/at least 1/,
);
assert.equal(
screen.getByTestId("local-archive-retention-save").disabled,
true,
);
});
test("a valid change saves through the invoke and reports the new value", async () => {
const setCalls = [];
ipcHandlers.set("set_observer_retention_days", (args) => {
setCalls.push(args);
return Promise.resolve(null);
});
let saved = null;
await act(async () => {
render(
createElement(ObserverRetentionSection, {
savedDays: 30,
onSaved: (d) => {
saved = d;
},
}),
);
});
const input = screen.getByTestId("local-archive-retention-days");
await act(async () => {
fireEvent.change(input, { target: { value: "14" } });
});
const save = screen.getByTestId("local-archive-retention-save");
assert.equal(save.disabled, false, "a changed valid value enables Save");
await act(async () => {
fireEvent.click(save);
});
assert.deepEqual(setCalls, [{ days: 14 }], "invoke receives the parsed days");
assert.equal(saved, 14, "onSaved reports the persisted value");
});
// ── ArchiveSizeSection ────────────────────────────────────────────────────────
test("size readout shows physical size and reclaimable bytes", async () => {
await act(async () => {
render(
createElement(ArchiveSizeSection, {
stats: {
mainFileBytes: 1024 ** 3,
walFileBytes: 512 * 1024 ** 2,
pageSize: 4096,
pageCount: 300_000,
freelistCount: 262_144, // 1 GB reclaimable
},
}),
);
});
assert.equal(
screen.getByTestId("local-archive-size-physical").textContent,
"1.5 GB",
);
const copy = screen.getByTestId("local-archive-size-section").textContent;
assert.match(copy, /1\.5 GB/);
assert.match(copy, /1\.0 GB is reclaimable/);
assert.match(copy, /only shrinks after an offline compaction/);
});
test("size readout shows a loading fallback before stats arrive", async () => {
await act(async () => {
render(createElement(ArchiveSizeSection, { stats: null }));
});
assert.equal(
screen.getByTestId("local-archive-size-physical").textContent,
"—",
);
assert.match(
screen.getByTestId("local-archive-size-section").textContent,
/Loading…/,
);
});

View File

@@ -3,11 +3,15 @@ import * as React from "react";
import { toast } from "sonner";
import {
archiveSizeStats,
createSaveSubscription,
deleteSaveSubscription,
getObserverRetentionDays,
listSaveSubscriptions,
mergeSaveSubscriptionKinds,
removeSaveSubscriptionKind,
setObserverRetentionDays,
type ArchiveSizeStats,
type SaveSubscription,
type ScopeType,
} from "@/shared/api/tauriArchive";
@@ -27,6 +31,7 @@ import {
import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader";
import { setExplicitAgentMetricArchiveChoice } from "../agentMetricArchivePreference";
import { setExplicitObserverArchiveChoice } from "../observerArchivePreference";
import { deriveSizeReadout, parseRetentionDays } from "../retentionSettings";
import {
buildSubscriptionRequest,
@@ -153,6 +158,134 @@ function AgentMetricArchiveSection({
);
}
// ── Observer retention section ────────────────────────────────────────────────
type RetentionSectionProps = {
/** Persisted value, or `null` while the initial load is in flight. */
savedDays: number | null;
onSaved: (days: number) => void;
};
export function ObserverRetentionSection({
savedDays,
onSaved,
}: RetentionSectionProps) {
const [draft, setDraft] = React.useState("");
const [isSaving, setIsSaving] = React.useState(false);
// Seed the field from the persisted value once it loads, and re-seed whenever
// it changes underneath us (e.g. a successful save elsewhere).
React.useEffect(() => {
if (savedDays !== null) setDraft(String(savedDays));
}, [savedDays]);
const parsed = parseRetentionDays(draft);
const invalid = !parsed.ok;
const unchanged =
parsed.ok && savedDays !== null && parsed.days === savedDays;
const canSave = parsed.ok && !unchanged && !isSaving;
const handleSave = React.useCallback(async () => {
if (!parsed.ok) return;
setIsSaving(true);
try {
await setObserverRetentionDays(parsed.days);
onSaved(parsed.days);
toast.success("Retention window updated.");
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to update retention.",
);
} finally {
setIsSaving(false);
}
}, [parsed, onSaved]);
return (
<div data-testid="local-archive-retention-section">
<SettingsOptionGroup
title="Observer frame retention"
description="Observer frames older than this many days are pruned from the local archive. NIP-AM turn metrics and every other archived kind are kept forever."
>
<div className="space-y-3 px-4 py-4">
<div className="flex items-end gap-2">
<div className="min-w-0">
<label
className="mb-1.5 block text-sm font-medium"
htmlFor="local-archive-retention-days"
>
Keep for (days)
</label>
<input
className="w-32 rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-testid="local-archive-retention-days"
disabled={savedDays === null}
id="local-archive-retention-days"
inputMode="numeric"
onChange={(e) => setDraft(e.target.value)}
placeholder="30"
type="text"
value={draft}
/>
</div>
<Button
data-testid="local-archive-retention-save"
disabled={!canSave}
onClick={() => void handleSave()}
type="button"
>
{isSaving ? "Saving…" : "Save"}
</Button>
</div>
{invalid && draft.trim() !== "" && (
<p
className="text-xs text-destructive"
data-testid="local-archive-retention-error"
>
{parsed.error}
</p>
)}
</div>
</SettingsOptionGroup>
</div>
);
}
// ── Archive size readout section ──────────────────────────────────────────────
export function ArchiveSizeSection({
stats,
}: {
stats: ArchiveSizeStats | null;
}) {
const readout = stats ? deriveSizeReadout(stats) : null;
return (
<div data-testid="local-archive-size-section">
<SettingsOptionGroup title="Storage">
<SettingsOptionRow>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">Archive size on disk</p>
<p
className="text-sm font-normal text-muted-foreground/70"
data-settings-subcopy
>
{readout === null
? "Loading…"
: `Physical size ${readout.physicalLabel} (database + write-ahead log). ${readout.reclaimableLabel} is reclaimable after pruning — freed pages become reusable space; the file only shrinks after an offline compaction.`}
</p>
</div>
<span
className="shrink-0 text-sm font-medium tabular-nums"
data-testid="local-archive-size-physical"
>
{readout?.physicalLabel ?? "—"}
</span>
</SettingsOptionRow>
</SettingsOptionGroup>
</div>
);
}
// ── Add-subscription form ─────────────────────────────────────────────────────
type KindChecklistProps = {
@@ -397,6 +530,10 @@ export function LocalArchiveSettingsCard() {
const [isAddingOpen, setIsAddingOpen] = React.useState(false);
const [observerToggling, setObserverToggling] = React.useState(false);
const [metricToggling, setMetricToggling] = React.useState(false);
const [retentionDays, setRetentionDays] = React.useState<number | null>(null);
const [sizeStats, setSizeStats] = React.useState<ArchiveSizeStats | null>(
null,
);
const pubkey = identityQuery.data?.pubkey ?? "";
@@ -428,6 +565,22 @@ export function LocalArchiveSettingsCard() {
void reload();
}, [reload]);
// Retention window + size readout load independently of the subscription
// list; a failure in either is non-fatal (the sections show their loading
// fallback) and must not blank the rest of the card.
React.useEffect(() => {
void getObserverRetentionDays()
.then(setRetentionDays)
.catch((err) =>
console.warn("[LocalArchiveSettingsCard] retention load failed:", err),
);
void archiveSizeStats()
.then(setSizeStats)
.catch((err) =>
console.warn("[LocalArchiveSettingsCard] size load failed:", err),
);
}, []);
const handleDelete = React.useCallback(
async (scopeType: ScopeType, scopeValue: string) => {
const key = `${scopeType}:${scopeValue}`;
@@ -543,6 +696,15 @@ export function LocalArchiveSettingsCard() {
toggling={metricToggling}
/>
{/* Observer frame retention window */}
<ObserverRetentionSection
onSaved={setRetentionDays}
savedDays={retentionDays}
/>
{/* Archive size readout */}
<ArchiveSizeSection stats={sizeStats} />
{/* Channel subscriptions */}
<div data-testid="local-archive-subscriptions">
{isLoading ? (

View File

@@ -99,6 +99,25 @@ export type AgentUsageSeriesRequest = {
agentPubkey?: string;
};
/**
* Physical and logical size accounting for the archive DB, from
* `archive_size_stats`. Mirrors `retention::ArchiveSizeStats`
* (`#[serde(rename_all = "camelCase")]`) field-for-field. All figures are cheap
* PRAGMAs + file metadata — no payload scans.
*/
export type ArchiveSizeStats = {
/** On-disk size of the main DB file, in bytes. */
mainFileBytes: number;
/** On-disk size of the `-wal` sidecar, in bytes; `0` when absent. */
walFileBytes: number;
/** SQLite page size, in bytes. */
pageSize: number;
/** Total pages in the main DB. */
pageCount: number;
/** Free (reclaimable) pages on the freelist. */
freelistCount: number;
};
// ── Wire-shape types (raw Tauri responses) ───────────────────────────────────
/**
@@ -555,3 +574,31 @@ export async function readArchivedEvents(
})
.filter((e): e is import("@/shared/api/types").RelayEvent => e !== null);
}
// ── Retention configuration ──────────────────────────────────────────────────
/**
* Read the global observer-frame (kind 24200) retention window, in days. Every
* other archived kind — NIP-AM metrics and any custom subscription — is kept
* indefinitely and has no setting.
*/
export async function getObserverRetentionDays(): Promise<number> {
return invokeTauri<number>("get_observer_retention_days");
}
/**
* Set the global observer-frame retention window, in days. The backend is
* fail-closed and rejects values outside `1..=36500`; callers validate the
* same bounds client-side before invoking (see `retentionSettings`).
*/
export async function setObserverRetentionDays(days: number): Promise<void> {
await invokeTauri("set_observer_retention_days", { days });
}
/**
* Physical (file) and logical (page) size accounting for the archive DB, for
* the Settings size readout. PRAGMAs + file metadata only — no payload scans.
*/
export async function archiveSizeStats(): Promise<ArchiveSizeStats> {
return invokeTauri<ArchiveSizeStats>("archive_size_stats");
}