Redesign the Storage Monitor as a storage-composition breakdown (snippets · datasets · app)

This commit is contained in:
2026-06-10 01:32:22 +03:00
parent e7de0cbb8a
commit 7599acd25e
12 changed files with 438 additions and 481 deletions
+31 -29
View File
@@ -344,52 +344,54 @@ const KEY = 'astrolabe:ux-prefs'; // { panelLayout: { libraryWidth, previewWidth
---
## 6. Storage Tiers, Budgets & Quota Monitoring
## 6. Storage Tiers & the Composition Monitor
Astrolabe has three tiers with different capacities and risk profiles:
| Tier | Backing | Holds | Budget & behavior |
| -------------------- | -------------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Snippet store** | IndexedDB `snippets` | All snippet records | Practical budget ~**5 MB**. A storage monitor estimates usage and surfaces a warning as it fills. Snippets are user-authored and irreplaceable, so we fail **loudly**. |
| **Dataset store** | IndexedDB `datasets` | All dataset payloads | Separate, **high-capacity**; suited to large payloads. Loaded in full today; lazy loading is a §3 target. |
| **Settings & prefs** | localStorage | `UserSettings` + app/UI prefs (§5) | Small; effectively unbounded for this use. |
| Tier | Backing | Holds | Behavior |
| -------------------- | -------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------- |
| **Snippet store** | IndexedDB `snippets` | All snippet records | Snippets are user-authored and irreplaceable, so writes fail **loudly** on quota (below). |
| **Dataset store** | IndexedDB `datasets` | All dataset payloads | Separate, **high-capacity**; suited to large payloads. Loaded in full today; lazy loading is a §3 target. |
| **Settings & prefs** | localStorage | `UserSettings` + app/UI prefs (§5) | Small; effectively unbounded for this use. |
Splitting snippets and datasets into separate stores means a few large datasets can't crowd out the snippet budget, and the snippet monitor can report a meaningful "how full is my library" number without summing dataset bytes.
Splitting snippets and datasets into separate stores means a few large datasets can't crowd out snippets, and lets the storage monitor break usage down **by tier**.
### Estimating usage
### Composing the storage breakdown
Read the origin's **Storage Manager** estimate (`navigator.storage.estimate()``usage`/`quota`) and derive a presentation-ready summary in the **pure core**, so the thresholds are unit-tested without a browser and the adapter only does I/O. When the API is missing or rejects, the summary is marked unusable rather than guessed.
The monitor shows what storage is **made of** — Snippets · Datasets · App — not a "used of quota" gauge. The browser's `quota` is a padded, deliberately fuzzed approximation, not a real free-space figure (web.dev → _storage-for-the-web_), so a budget fraction is false precision. We use only the reliable `usage` (bytes actually stored for the origin) and measure our own tiers, deriving the rest:
- **Snippets / Datasets** — measured directly: snippets serialized (`jsonByteSize`), datasets summed from each record's `size`. Always available, no API needed.
- **App** = `usage snippets datasets` — the precached app shell + IndexedDB overhead. Shown only when the Storage Manager API yields `usage`; otherwise the breakdown is just snippets + datasets.
- **Hidden below a floor.** The whole monitor stays hidden until _user_ data (snippets + datasets) reaches `STORAGE_MONITOR_MIN_USER_BYTES` (10 MB). Keyed off user bytes, not total: the ~precache baseline is roughly constant, so gating on total would make it always-visible and the bar App-dominated.
The pure summarizer lives in core (unit-tested without a browser); the adapter only does I/O.
```ts
// src/core/storage-estimate.ts — pure, unit-tested (no I/O)
export type StorageLevel = 'ok' | 'warning' | 'critical';
export interface StorageSummary {
usedBytes: number;
quotaBytes: number;
fraction: number; // usedBytes / quotaBytes, clamped 0..1
level: StorageLevel; // escalates from `fraction`
available: boolean; // false when usage/quota are missing/invalid
export interface StorageComposition {
segments: { key: 'snippets' | 'datasets' | 'app'; label: string; bytes: number }[];
totalBytes: number; // origin usage when measured, else snippets + datasets
originMeasured: boolean; // false when the estimate API is absent
}
export const WARNING_THRESHOLD = 0.8; // begin warning
export const CRITICAL_THRESHOLD = 0.95; // escalate to "nearly full"
export function summarizeStorage(input: { usage?: number; quota?: number }): StorageSummary {
// Clamp the fraction, derive the level, and mark `available: false` when
// usage/quota are absent or non-finite — we don't warn on data we lack.
export function summarizeStorage(input: {
usageBytes?: number;
snippetBytes: number;
datasetBytes: number;
}): StorageComposition {
/* app = usage known, included only when usage ≥ known (else estimate lag) */
}
export function jsonByteSize(value: unknown): number; // UTF-8 bytes of JSON.stringify(value)
// src/app/infrastructure/storage-estimate.ts — the only browser-touching part
export async function readStorageEstimate(): Promise<StorageSummary> {
export async function readOriginUsage(): Promise<number | undefined> {
const storage = typeof navigator !== 'undefined' ? navigator.storage : undefined;
if (!storage || typeof storage.estimate !== 'function') return summarizeStorage({});
const { usage, quota } = await storage.estimate();
return summarizeStorage({ usage, quota });
if (!storage || typeof storage.estimate !== 'function') return undefined;
const { usage } = await storage.estimate(); // quota deliberately ignored
return typeof usage === 'number' ? usage : undefined;
}
```
> **Open divergence (spec §02 ↔ code).** Spec §02 frames the monitor as the **snippet** storage budget specifically ("datasets are stored separately with far greater capacity"). The shipped `readStorageEstimate` instead reports **whole-origin** `usage`/`quota` (snippets + datasets + everything the origin holds). Reconcile deliberately — either scope the estimate to the snippet store, or update spec §02 to describe the whole-origin reading.
**The bar is decorative; the legend is the data.** There is no `role="meter"` — a meter needs a meaningful maximum, which a composition with no fixed ceiling lacks (APG → meter). The legend's text labels + sizes are the accessible source of truth, so meaning never rests on hue (WCAG 1.4.1). Dropping the old 0.8/0.95 "almost full" thresholds is deliberate: they keyed off the untrustworthy quota; the genuine out-of-room event surfaces at save time (below), where it is accurate. (Resolves the former spec §02 ↔ code "whole-origin vs. snippet budget" divergence; council resolution recorded in `docs/architecture/10`.)
### Fail loudly, never silently lose data