diff --git a/desktop/src/features/local-archive/retentionSettings.test.mjs b/desktop/src/features/local-archive/retentionSettings.test.mjs new file mode 100644 index 0000000000..f6568b35f9 --- /dev/null +++ b/desktop/src/features/local-archive/retentionSettings.test.mjs @@ -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"); +}); diff --git a/desktop/src/features/local-archive/retentionSettings.ts b/desktop/src/features/local-archive/retentionSettings.ts new file mode 100644 index 0000000000..3e8fd59039 --- /dev/null +++ b/desktop/src/features/local-archive/retentionSettings.ts @@ -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), + }; +} diff --git a/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.test.mjs b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.test.mjs new file mode 100644 index 0000000000..58069ade55 --- /dev/null +++ b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.test.mjs @@ -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("", { + 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…/, + ); +}); diff --git a/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx index c03faa0948..3854df57bf 100644 --- a/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx +++ b/desktop/src/features/local-archive/ui/LocalArchiveSettingsCard.tsx @@ -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 ( +
+ +
+
+
+ + setDraft(e.target.value)} + placeholder="30" + type="text" + value={draft} + /> +
+ +
+ {invalid && draft.trim() !== "" && ( +

+ {parsed.error} +

+ )} +
+
+
+ ); +} + +// ── Archive size readout section ────────────────────────────────────────────── + +export function ArchiveSizeSection({ + stats, +}: { + stats: ArchiveSizeStats | null; +}) { + const readout = stats ? deriveSizeReadout(stats) : null; + return ( +
+ + +
+

Archive size on disk

+

+ {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.`} +

+
+ + {readout?.physicalLabel ?? "—"} + +
+
+
+ ); +} + // ── 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(null); + const [sizeStats, setSizeStats] = React.useState( + 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 */} + + + {/* Archive size readout */} + + {/* Channel subscriptions */}
{isLoading ? ( diff --git a/desktop/src/shared/api/tauriArchive.ts b/desktop/src/shared/api/tauriArchive.ts index 10fbb733ca..5018b8e0f4 100644 --- a/desktop/src/shared/api/tauriArchive.ts +++ b/desktop/src/shared/api/tauriArchive.ts @@ -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 { + return invokeTauri("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 { + 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 { + return invokeTauri("archive_size_stats"); +}