mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
123 lines
5.0 KiB
TypeScript
123 lines
5.0 KiB
TypeScript
/**
|
||
* 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]}`;
|
||
}
|