Format entire codebase with Prettier (mechanical, no behavior change)

This commit is contained in:
2026-06-05 01:43:28 +03:00
parent 939950b136
commit 0c7297624e
32 changed files with 1597 additions and 832 deletions
+44 -31
View File
@@ -1,6 +1,6 @@
# 02 · Persistence Architecture
How Astrolabe stores data in the browser, and the rules that keep that storage testable, portable, and safe to evolve. This document is the implementation contract for the persistence layer. For the *behavioral* data model (what fields a Snippet or Dataset has, what the tiers hold), see [09 · Data Model & Persistence](../spec/09-data-model.md); this document covers *how the code is structured to implement it*.
How Astrolabe stores data in the browser, and the rules that keep that storage testable, portable, and safe to evolve. This document is the implementation contract for the persistence layer. For the _behavioral_ data model (what fields a Snippet or Dataset has, what the tiers hold), see [09 · Data Model & Persistence](../spec/09-data-model.md); this document covers _how the code is structured to implement it_.
---
@@ -39,7 +39,7 @@ IndexedDB's native API is event-based (`onsuccess`/`onerror`) and verbose. The a
### 2.1 Opening the database
Open with an explicit **version number** and an `onupgradeneeded` handler that creates/upgrades object stores. The version is a monotonically increasing integer; bump it whenever the *store layout* changes (a new object store, a new index). It is independent of per-record schema versions (§4).
Open with an explicit **version number** and an `onupgradeneeded` handler that creates/upgrades object stores. The version is a monotonically increasing integer; bump it whenever the _store layout_ changes (a new object store, a new index). It is independent of per-record schema versions (§4).
```ts
// src/app/infrastructure/db.ts
@@ -96,7 +96,7 @@ function wrap<T>(req: IDBRequest<T>): Promise<T> {
async function tx<T>(
store: string,
mode: IDBTransactionMode,
run: (s: IDBObjectStore) => IDBRequest<T>
run: (s: IDBObjectStore) => IDBRequest<T>,
): Promise<T> {
const db = await openDB();
return new Promise<T>((resolve, reject) => {
@@ -171,7 +171,7 @@ export async function ensureDatasetData(dataset: Dataset): Promise<Dataset['data
## 4. Per-Record Schema Versioning & Read-Time Migration
The IndexedDB **database version** (§2.1) governs *store layout*. A separate **per-record `version` field** governs the *shape of an individual record*. Both Snippet and Dataset records carry `version` (and `created` / `modified` timestamps). This lets record shapes evolve without forcing an `onupgradeneeded` database bump for every field rename.
The IndexedDB **database version** (§2.1) governs _store layout_. A separate **per-record `version` field** governs the _shape of an individual record_. Both Snippet and Dataset records carry `version` (and `created` / `modified` timestamps). This lets record shapes evolve without forcing an `onupgradeneeded` database bump for every field rename.
Migrations are applied **on read** — when a record comes out of the store, run it through a migration function that upgrades it to the current shape before the app sees it. New writes always store the current version.
@@ -204,14 +204,18 @@ export async function loadSnippets(): Promise<Snippet[]> {
}
export async function saveSnippet(s: Snippet): Promise<void> {
await put('snippets', { ...s, version: CURRENT_SNIPPET_VERSION, modified: new Date().toISOString() });
await put('snippets', {
...s,
version: CURRENT_SNIPPET_VERSION,
modified: new Date().toISOString(),
});
}
```
### Rationale
- **Read-time migration is forgiving.** Old records sitting untouched in the store keep working; they upgrade lazily the next time they're loaded and re-saved. There is no big-bang migration step that can fail halfway.
- **Tolerate unknown fields.** A migration normalizes *missing/old* fields but must not strip fields it doesn't recognize — a record written by a *newer* build that downgraded must round-trip without data loss. Spread the original (`{ ...raw }`) and only fill in what's missing.
- **Tolerate unknown fields.** A migration normalizes _missing/old_ fields but must not strip fields it doesn't recognize — a record written by a _newer_ build that downgraded must round-trip without data loss. Spread the original (`{ ...raw }`) and only fill in what's missing.
- **One function, well tested.** Each migration step is a pure function over a plain object — trivial to unit-test with fixture records from each historical version.
> **Do:** default `version` to the earliest shape (`1`) when the field is absent.
@@ -237,8 +241,14 @@ export const CURRENT_SETTINGS_VERSION = 1;
export interface UserSettings {
version: number;
editor: { fontSize: number; theme: string; minimap: boolean; wordWrap: 'on' | 'off';
lineNumbers: 'on' | 'off'; tabSize: number };
editor: {
fontSize: number;
theme: string;
minimap: boolean;
wordWrap: 'on' | 'off';
lineNumbers: 'on' | 'off';
tabSize: number;
};
performance: { renderDebounce: number };
ui: { theme: 'light' | 'dark'; previewFitMode: 'default' | 'width' | 'height' | 'full' };
formatting: { dateFormat: 'smart' | 'iso' | 'custom'; customDateFormat: string };
@@ -248,8 +258,14 @@ export interface UserSettings {
// contract; this is just where it's encoded.
const DEFAULTS: UserSettings = {
version: CURRENT_SETTINGS_VERSION,
editor: { fontSize: 12, theme: 'auto', minimap: false, wordWrap: 'on',
lineNumbers: 'on', tabSize: 2 },
editor: {
fontSize: 12,
theme: 'auto',
minimap: false,
wordWrap: 'on',
lineNumbers: 'on',
tabSize: 2,
},
performance: { renderDebounce: 1500 },
ui: { theme: 'light', previewFitMode: 'default' },
formatting: { dateFormat: 'smart', customDateFormat: '' },
@@ -323,11 +339,11 @@ const SORT_DEFAULTS = { sortBy: 'modified' as const, sortOrder: 'desc' as const
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. Lazily loaded (§3). |
| **Settings & prefs** | localStorage | `UserSettings` + app/UI prefs (§5) | Small; effectively unbounded for this use. |
| 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. Lazily loaded (§3). |
| **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.
@@ -338,10 +354,10 @@ Use the Storage Manager API where available, with a manual byte-sum fallback for
```ts
// src/app/infrastructure/storage-monitor.ts
export interface StorageReport {
snippetBytes: number; // estimated bytes used by the snippet tier
snippetBudget: number; // 5 MB practical budget
ratio: number; // snippetBytes / snippetBudget, clamped to >= 0
warn: boolean; // ratio crossed the warning threshold
snippetBytes: number; // estimated bytes used by the snippet tier
snippetBudget: number; // 5 MB practical budget
ratio: number; // snippetBytes / snippetBudget, clamped to >= 0
warn: boolean; // ratio crossed the warning threshold
}
const SNIPPET_BUDGET = 5 * 1024 * 1024;
@@ -349,10 +365,7 @@ const WARN_AT = 0.8;
export async function reportSnippetUsage(snippets: Snippet[]): Promise<StorageReport> {
// Cheap, deterministic estimate: serialize the records we hold.
const snippetBytes = snippets.reduce(
(n, s) => n + new Blob([JSON.stringify(s)]).size,
0
);
const snippetBytes = snippets.reduce((n, s) => n + new Blob([JSON.stringify(s)]).size, 0);
const ratio = snippetBytes / SNIPPET_BUDGET;
const report: StorageReport = {
snippetBytes,
@@ -361,9 +374,7 @@ export async function reportSnippetUsage(snippets: Snippet[]): Promise<StorageRe
warn: ratio >= WARN_AT,
};
if (report.warn) {
console.warn(
`[storage] snippet tier ${(ratio * 100).toFixed(0)}% of ${SNIPPET_BUDGET} bytes`
);
console.warn(`[storage] snippet tier ${(ratio * 100).toFixed(0)}% of ${SNIPPET_BUDGET} bytes`);
}
return report;
}
@@ -380,15 +391,17 @@ export async function saveSnippet(s: Snippet): Promise<void> {
} catch (err) {
if (err instanceof DOMException && err.name === 'QuotaExceededError') {
// Surface to the user via the store; do NOT silently drop the write.
throw new StorageQuotaError('Snippet storage is full. Export and remove snippets to free space.');
throw new StorageQuotaError(
'Snippet storage is full. Export and remove snippets to free space.',
);
}
throw err;
}
}
```
> **Do:** surface quota warnings *before* the budget is hit (the 80% threshold) and hard errors loudly when a write fails.
> **Don't:** wrap a save in a bare `try/catch {}` that logs and returns — that turns "your work wasn't saved" into a silent data-loss bug. The only thing the adapter may safely swallow is a *read* failure, where falling back to defaults/empty is the correct behavior.
> **Do:** surface quota warnings _before_ the budget is hit (the 80% threshold) and hard errors loudly when a write fails.
> **Don't:** wrap a save in a bare `try/catch {}` that logs and returns — that turns "your work wasn't saved" into a silent data-loss bug. The only thing the adapter may safely swallow is a _read_ failure, where falling back to defaults/empty is the correct behavior.
---
@@ -396,8 +409,8 @@ export async function saveSnippet(s: Snippet): Promise<void> {
1. Define the record type with `id`, `created`, `modified`, and a `version` field.
2. Decide the tier: small + critical → IndexedDB store with a monitored budget; large payload → separate high-capacity store with lazy loading (§3); tiny + frequently changing → localStorage pref (§5).
3. Add the object store in `openDB`'s `onupgradeneeded`, guarded by `contains(...)`; bump `DB_VERSION` only if you changed store *layout*.
3. Add the object store in `openDB`'s `onupgradeneeded`, guarded by `contains(...)`; bump `DB_VERSION` only if you changed store _layout_.
4. Add a `migrate<Entity>()` function and call it on every read.
5. Expose typed `load*/save*/ensure*` functions from one infrastructure module — and from *only* there.
5. Expose typed `load*/save*/ensure*` functions from one infrastructure module — and from _only_ there.
6. If the tier has a budget, hook it into the storage monitor and propagate `QuotaExceededError`.
7. Test the adapter against `fake-indexeddb` / a localStorage stub; test the migration with fixtures from each historical version.