Files
astrolabe/src/core/storage-estimate.ts
T

123 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Storage composition summarization (spec §02 → Storage Monitor; docs/architecture/02 §6).
*
* Portable core: no browser APIs, no React. The Storage Monitor is **not** a "used
* of quota" gauge — the browser's `quota` from `navigator.storage.estimate()` is a
* padded, browser-decided approximation, not a real free-space figure, so a budget
* fraction would be false precision (web.dev → storage-for-the-web). Instead we show
* what storage is *made of*: Snippets, Datasets, and App (everything else — the
* precached app shell + IndexedDB overhead). This module turns three measured byte
* counts into ordered segments; the math lives here so it is trivially testable.
*
* The only reliable figure from the estimate is `usage` (bytes actually stored for
* the whole origin). Snippet and dataset bytes we measure ourselves, so
* `App = usage snippets datasets`. When the estimate is missing we still show
* snippets + datasets from our own data.
*/
/** A category of stored data and its measured size in bytes. */
interface StorageSegment {
key: 'snippets' | 'datasets' | 'app';
/** User-facing label, centralized here so the component stays presentational. */
label: string;
bytes: number;
}
/** Measured inputs: snippet/dataset bytes (always known) + origin usage (when the API is present). */
export interface StorageInput {
/** Whole-origin bytes from `navigator.storage.estimate().usage`, if available. */
usageBytes?: number;
/** Summed serialized size of all snippets. */
snippetBytes: number;
/** Summed size of all datasets (each `Dataset` carries its byte `size`). */
datasetBytes: number;
}
/** Presentation-ready storage composition. */
export interface StorageComposition {
/** Ordered segments to display: Snippets, Datasets, then App when measurable. */
segments: StorageSegment[];
/** Sum of the segments shown — origin `usage` when measured, else snippets+datasets. */
totalBytes: number;
/**
* True when the origin estimate was available, so the `app` segment (the precached
* shell + overhead) is meaningful and `totalBytes` is whole-origin usage. False when
* the Storage Manager API is absent — we still show snippets + datasets, and
* `totalBytes` is just their sum.
*/
originMeasured: boolean;
}
/**
* The monitor stays hidden until the user's own data — snippets + datasets — reaches
* this size (spec §02). Below it storage is noise (nothing to manage), and the bar
* would be dominated by the immovable precached app shell anyway. Deliberately keyed
* off *user* bytes, not total: the ~precache baseline is roughly constant, so gating
* on total would make the monitor always-visible and the breakdown unbalanced.
*/
export const STORAGE_MONITOR_MIN_USER_BYTES = 10 * 1024 * 1024;
/** A finite, non-negative byte count, or null when the value is unusable. */
function usableBytes(n: number | undefined): number | null {
if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) return null;
return n;
}
/** A finite, non-negative byte count; junk degrades to 0. */
function nonNeg(n: number): number {
return usableBytes(n) ?? 0;
}
/**
* Decompose measured storage into ordered segments. The `app` segment (everything
* beyond our snippets+datasets) is included only when the origin `usage` is available
* and at least our measured data — a smaller estimate is the browser's approximation
* lagging, not real, so we fall back to a snippets+datasets-only view.
*/
export function summarizeStorage(input: StorageInput): StorageComposition {
const snippets = nonNeg(input.snippetBytes);
const datasets = nonNeg(input.datasetBytes);
const usage = usableBytes(input.usageBytes);
const known = snippets + datasets;
const segments: StorageSegment[] = [
{ key: 'snippets', label: 'Snippets', bytes: snippets },
{ key: 'datasets', label: 'Datasets', bytes: datasets },
];
if (usage !== null && usage >= known) {
segments.push({ key: 'app', label: 'App', bytes: usage - known });
return { segments, totalBytes: usage, originMeasured: true };
}
return { segments, totalBytes: known, originMeasured: false };
}
/** UTF-8 byte length of a value's JSON serialization (0 when it can't be serialized). */
export function jsonByteSize(value: unknown): number {
try {
return new TextEncoder().encode(JSON.stringify(value) ?? '').length;
} catch {
return 0;
}
}
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const;
/**
* Render a byte count as a human-readable string using binary (1024) units.
* Bytes are shown as a whole number with the `B` unit; KB and up get one
* decimal place (e.g. `1.5 MB`). Negative/non-finite inputs degrade to `0 B`.
*/
export function humanizeBytes(n: number): string {
if (!Number.isFinite(n) || n < 0) return '0 B';
if (n < 1024) return `${Math.round(n)} B`;
let value = n;
let unit = 0;
while (value >= 1024 && unit < BYTE_UNITS.length - 1) {
value /= 1024;
unit += 1;
}
return `${value.toFixed(1)} ${BYTE_UNITS[unit]}`;
}