Persistence: share entity write-through helper; normalize quota at db.put

This commit is contained in:
2026-06-16 23:06:37 +03:00
parent 778b4d848a
commit 2ed9db3792
18 changed files with 422 additions and 205 deletions
+20 -19
View File
@@ -426,26 +426,25 @@ export async function readOriginUsage(): Promise<number | undefined> {
### Fail loudly, never silently lose data
When a write would exceed quota, IndexedDB rejects with a `QuotaExceededError`. The adapter must **propagate** this so the UI can tell the user to export and prune — it must never swallow the error and pretend the save succeeded.
When a write would exceed quota, IndexedDB rejects with a `QuotaExceededError`. Quota is
whole-origin, so it's normalized **once** at the single write path — `db.put` — into a typed
`StorageQuotaError`. Every typed adapter inherits fail-loud behavior without repeating the
check, and consumers branch on the type instead of sniffing a `DOMException`.
```ts
export async function saveSnippet(s: Snippet): Promise<void> {
try {
await put('snippets', { ...s, version: CURRENT_SNIPPET_VERSION });
} catch (err) {
// src/app/infrastructure/db.ts — the one write path
export const put = <T>(store: string, value: T): Promise<IDBValidKey> =>
tx<IDBValidKey>(store, 'readwrite', (s) => s.put(value)).catch((err: unknown) => {
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(); // never silently drop the write
}
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.
> **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 that may safely swallow an error is a _read_ failure, where falling back to defaults/empty is the correct behavior.
> **Rule:** every write goes through `db.put`. An adapter that opens its own `tx(store, 'readwrite', …)` instead bypasses quota normalization and silently loses the typed `StorageQuotaError` — a regression the type system won't catch.
**The adapter propagating is only half — a consumer must catch and surface it.** A
fire-and-forget `void saveSnippet(n)` re-buries the very error the adapter took care to
@@ -456,10 +455,12 @@ throw. Persistence write-backs are wired as store subscribers, so the surfacing
`notify()` (`stores/NotificationStore`) → `Toaster`.
Rules this encodes (spec §10 "told when a save fails"): never `void`-fire a persist without
a `.catch` that calls `notify(storageErrorNotification(op, err))`; the mapper splits
user-fixable (storage full → next step, no diagnostic) from not (blocked storage → plain
explanation **+** a reportable `detail`); and a blocked store at startup **warns and runs
in memory** rather than rejecting into the void.
a `.catch` that maps the error to a toast — `storageErrorNotification(op, err)` for snippets
(bespoke "your library" / "your changes" copy), `entityStorageErrorNotification(noun, op, err)`
for the other tiers (the same shape with the entity's own noun). The mapper splits user-fixable
(storage full → next step, no diagnostic) from not (blocked storage → plain explanation **+** a
reportable `detail`); and a blocked store at startup **warns and runs in memory** rather than
rejecting into the void.
### Multi-record writes (import): atomicity at the service boundary
@@ -492,9 +493,9 @@ spec §08 "no partial import is committed" contract holds and the user gets an a
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.
6. Add the app layer: a Zustand store whose low-level `add`/`update`/`remove` are the single mutation point for the collection, and a diffing **write-through subscriber** in `orchestration/` (the `dataset-persistence.ts` shape: compare the array against the previous snapshot, upsert changed records, delete missing ones, toast on failure).
6. Add the app layer: a Zustand store whose low-level `add`/`update`/`remove` are the single mutation point for the collection, and a **write-through subscriber** in `orchestration/` — a thin wrapper over the shared `wireEntityWriteThrough(store, select, { save, remove, onError })` helper (`entity-persistence.ts`), which diffs the array against the previous snapshot, upserts changed records, deletes missing ones, and toasts on failure.
7. Hydrate in `orchestration/startup.ts` and wire the subscriber **after** hydrate — wiring first would re-save every loaded record on each startup.
8. If the tier has a budget, hook it into the storage monitor and propagate `QuotaExceededError`.
8. Quota propagation is automatic (`db.put` throws `StorageQuotaError`) — just pass an `onError` that maps it via `entityStorageErrorNotification(noun, …)`. If the tier has a budget, also hook it into the storage monitor.
9. Test the adapter against `fake-indexeddb` / a localStorage stub; test the migration with fixtures from each historical version.
The stack for one entity is four files with fixed roles: `infrastructure/<entity>-store.ts` (typed IDB adapter) + `infrastructure/<entity>-migrations.ts` (read-time upgrade) + `stores/<Entity>Store.ts` (in-memory collection + feature state) + `orchestration/<entity>-persistence.ts` (write-through), joined in `startup.ts`. Snippets, datasets, and custom themes each follow it.
The stack for one entity is four files with fixed roles: `infrastructure/<entity>-store.ts` (typed IDB adapter) + `infrastructure/<entity>-migrations.ts` (read-time upgrade) + `stores/<Entity>Store.ts` (in-memory collection + feature state) + `orchestration/<entity>-persistence.ts` (write-through, a thin call to the shared `wireEntityWriteThrough`), joined in `startup.ts`. Snippets, datasets, custom themes, and user fonts each follow it.