mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Persistence: share entity write-through helper; normalize quota at db.put
This commit is contained in:
+26
-22
@@ -51,7 +51,7 @@ doc before implementing.
|
||||
| **M3** | Datasets ✅ | Named reusable data + reference resolution in preview | §05, §03F, §09B |
|
||||
| **M4** | Chart Builder ✅ | No-JSON chart composition from a dataset | §06 |
|
||||
| **M5** | Settings + Import/Export ✅ | Preferences + workspace backup/transfer | §07, §08, §09C |
|
||||
| **M6** | Shell polish | Resize/toggle panes, routing, shortcuts, toasts, a11y, offline | §01, §10 |
|
||||
| **M6** | Shell polish ✅ | Resize/toggle panes, routing, shortcuts, toasts, a11y, offline | §01, §10 |
|
||||
|
||||
**MVP boundary = end of M1** (a genuinely usable single-user chart authoring loop).
|
||||
M1.5 makes it _look right_; M2 makes it _robust_; M3–M6 make it _complete_.
|
||||
@@ -428,10 +428,15 @@ through `services/transfer.ts` (→ `normalizeImport` / envelope build), no moda
|
||||
|
||||
---
|
||||
|
||||
## M6 · Shell polish & non-functional
|
||||
## M6 · Shell polish & non-functional ✅ (done)
|
||||
|
||||
**Goal:** the workspace feels finished and meets §10.
|
||||
|
||||
**Scope note — desktop/tablet, not phone.** Astrolabe is a desktop and (at best)
|
||||
tablet tool; phones are out of scope. So the touch/installable surface we target is
|
||||
**iPad add-to-home-screen**, not iPhone — which is why the one PNG icon is a 180×180
|
||||
`apple-touch-icon` and we don't chase phone-specific viewport/layout work.
|
||||
|
||||
- **Panes:** ~~drag-resize handles with min widths; widths persist~~ ✅ (pulled
|
||||
forward after M2). ~~Per-pane show/hide **toggle strip** + visibility persist +
|
||||
proportional redistribution on hide (§01A, §09D)~~ ✅.
|
||||
@@ -454,13 +459,13 @@ through `services/transfer.ts` (→ `normalizeImport` / envelope build), no moda
|
||||
IDB-transaction atomicity would require exposing a raw transaction from `db.ts` — see
|
||||
[arch 02](architecture/02-persistence.md)).
|
||||
- **A11y:** modal focus trap + return, labelled icon buttons, contrast in both themes (§10) — ✅ in place.
|
||||
- **About** and **Donate** modals ~~(§01)~~ ✅ (Donate URL is a placeholder
|
||||
`DONATE_URL` pending the real link).
|
||||
- **Offline/installable:** the manifest now ships a full SVG icon set (favicon / maskable /
|
||||
monochrome) + `theme_color`, and the SW precaches the shell — the app is **installable**.
|
||||
**⏳ Remaining** — manual verification in a running/installed app (checklist:
|
||||
[manual-verification.md](manual-verification.md)); iOS home-screen still wants a PNG
|
||||
`apple-touch-icon` (logged residual).
|
||||
- **About** and **Donate** modals ~~(§01)~~ ✅. The project solicits nothing for
|
||||
itself — **Donate** redirects to Ukraine's defense (`savelife.in.ua`, the Come Back
|
||||
Alive foundation), where the author forwards any project donations anyway.
|
||||
- **Offline/installable:** the manifest ships a full SVG icon set (favicon / maskable /
|
||||
monochrome) + a 180×180 `apple-touch-icon.png` (iPad add-to-home-screen) + `theme_color`,
|
||||
and the SW precaches the shell — the app is **installable**. ✅ Manual verification in a
|
||||
running/installed app passed (checklist: [manual-verification.md](manual-verification.md)).
|
||||
- **Council** — ~~seat **web.dev** for the PWA/offline/storage surfaces none of the seated
|
||||
members cover: the service-worker **update-available** prompt (`registerType: 'prompt'`),
|
||||
storage **persistence** (`navigator.storage.persist()`), and the quota **estimate**
|
||||
@@ -470,9 +475,10 @@ through `services/transfer.ts` (→ `normalizeImport` / envelope build), no moda
|
||||
[`/council`](../.claude/skills/council/SKILL.md) and
|
||||
[arch 10](architecture/10-interaction-and-feedback.md).
|
||||
|
||||
**Manual checks:** keyboard-only run-through; reload restores view from URL;
|
||||
offline reload works; install as standalone; reduced-motion honored. **⏳ Still owed**
|
||||
(plus visual verification of the new library/monitor/modals/busy-indicator surfaces).
|
||||
**Manual checks:** ✅ keyboard-only run-through; reload restores view from URL;
|
||||
offline reload works; install as standalone; reduced-motion honored — all verified in a
|
||||
running/installed app, plus visual verification of the library/monitor/modals/busy-indicator
|
||||
surfaces.
|
||||
|
||||
---
|
||||
|
||||
@@ -487,7 +493,9 @@ offline reload works; install as standalone; reduced-motion honored. **⏳ Still
|
||||
boundary. M1–M6 can ship English-only with date formatting locale-aware (§10).
|
||||
Don't retrofit later if avoidable — keep user-facing strings centralized from M1.
|
||||
- **Versioning:** simplified semver `0.x.y`, `package.json` → `__APP_VERSION__`
|
||||
(already wired). Bump per shippable milestone.
|
||||
(already wired). **Not yet released publicly** — the working version stays pre-1.0 through
|
||||
M0–M6; the **first public release will be `1.0.0`**, cut on the maintainer's signal (don't
|
||||
auto-bump in the meantime).
|
||||
- **Docs trio:** keep `SOUL.md` / `AGENTS.md` / `CLAUDE.md` current as the app grows.
|
||||
|
||||
---
|
||||
@@ -528,20 +536,16 @@ is in the maintained plan, not the archive.
|
||||
|
||||
**Chart Builder** (`exploration/chart-builder-enhancement-scope.md`):
|
||||
|
||||
- **Starter examples + open-in-builder (3B/3C)** — the next builder slice in the scope doc:
|
||||
a starter-example front door that hydrates the builder from an existing spec (strict).
|
||||
- **Open-in-builder + builder starter examples (3B/3C)** — the next builder slice in the
|
||||
scope doc: hydrate the **builder** from an existing spec (strict spec→config parse), behind
|
||||
a starter-example front door. Distinct from the shipped onboarding gallery
|
||||
(`core/examples.ts` → `Onboarding.tsx`), which seeds a snippet straight into the editor, not
|
||||
the builder — there is no spec→builder-config path yet.
|
||||
- **Transform-aware data inspector** — show resolved post-transform rows
|
||||
(`exploration/data-inspector-exploration.md`).
|
||||
- **Field-chip drag-and-drop** — click/keyboard-first shipped; drag deferred.
|
||||
- **Calculated-field autocomplete popup** — Monaco-style completion for expressions.
|
||||
|
||||
**Engineering follow-ups** (refactor candidates, not features):
|
||||
|
||||
- **Entity-persistence subscriber consolidation** — snippet/dataset/theme/font each carry a
|
||||
near-identical diff-prev/next write-through subscriber (`orchestration/*-persistence.ts`);
|
||||
a generic `wireEntityPersistence(store, { save, delete })` would replace the four. Earned
|
||||
by the four call sites; hold only if per-entity clarity is judged to outweigh the dedupe.
|
||||
|
||||
---
|
||||
|
||||
## Architecture reference
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -8,9 +8,14 @@ record the resolution into the contract (`docs/architecture/09`+`10` and the rel
|
||||
|
||||
## Open
|
||||
|
||||
_(empty — the 2026-06-13 batch resolved all eleven open items; resolutions live in
|
||||
spec §01B/§02/§03D, arch 09 §5 (header worked example), and arch 10 §5 (toggle-strip
|
||||
pressed cue, SelectControl action row + trigger cap, no-affordance-for-unbuilt-features).)_
|
||||
- **Storage-full copy implies a per-tier budget, but quota is whole-origin** — the messages
|
||||
say "snippet storage is full" / "dataset storage is full" and tell the user to delete that
|
||||
entity's items, yet IndexedDB quota is shared across the whole origin. Per-tier framing is
|
||||
more _actionable_ (deleting the tier you're saving into does free space) but misstates the
|
||||
scope. Decide: keep the actionable per-tier framing, or switch to a whole-origin "Storage is
|
||||
full — free space (the Storage monitor shows what's using it)". Affects both
|
||||
`storageErrorNotification` and `entityStorageErrorNotification` in `services/storage-errors.ts`
|
||||
and the import-quota copy in `services/transfer.ts`.
|
||||
|
||||
## Deferred (not design debts, revisit on demand)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user