Client storage
@flyze/lib-core-angular 1.0.0-alpha.34· latestFlyzeStorageService reads, writes and deletes opaque JSON, sending each key to the storage location
its path is routed to. It is the only piece of the storage module application code touches.
await storage.write('color-scheme', 'dark');const scheme = await storage.read<string>('color-scheme'); // 'dark', or nullawait storage.delete('color-scheme');The service is providedIn: 'root' and needs no setup: with no routes registered it is a guarded
localStorage wrapper. Registering a route re-points a whole key path at another provider without a
call site changing. Why it is routed and why the contract is async: ADR
0012. How to use it in an app:
guides/storing-client-state.
The module is data-agnostic: no models, no schema, no knowledge of which keys exist or what they
mean, and no awareness of who is signed in. Restoring a value and applying it is the app’s job,
exactly as it is for ui-scale and theming, neither of which stores
anything itself. It imports nothing but @angular/core.
Why a router rather than a localStorage helper
Section titled “Why a router rather than a localStorage helper”Different key paths need different storage behavior, and which paths those are is an application question. A window size or a collapsed panel describes the device and must not follow a user to another machine; a language or a theme describes the person and, on a shared browser, must not leak from one to the next. A draft should die with the tab. A cached list is too large for web storage at all.
Rather than guess, the library routes: an app declares which prefix goes where, and supplies its own
provider for anything the built-ins do not cover. That is also the seam a backend arrives on — a
provider written in the app or in @flyze/lib-data-store takes over its route and may wrap a
built-in one as its first-paint cache. One registration changes.
The mechanism
Section titled “The mechanism”Three pieces: a provider contract, a set of built-in providers, and a router keyed by path prefix.
flowchart LR subgraph app["consumer app"] C["storage.read('fyzUser.language')"] R["FLYZE_STORAGE_ROUTES<br/>multi InjectionToken"] OWN["your own provider<br/>user-scoped, API-backed, …"] end
subgraph lib["built into this library"] S["FlyzeStorageService<br/>longest-prefix router"] LS["FlyzeLocalStorageProvider"] SS["FlyzeSessionStorageProvider"] IDB["FlyzeIndexedDbProvider"] MEM["FlyzeMemoryStorageProvider"] end
C --> S R -. registers routes .-> S S -->|"fyzUser. route"| OWN S -->|"draft. route"| SS S -->|"cache. route"| IDB S -->|"no match: default"| LS OWN -. "may wrap one as a cache" .-> LSEvery provider implements the same three-method FlyzeStorageProvider contract, so the router does
not care which one answers — including one the app wrote, which is indistinguishable from a built-in
to everything upstream. The full key, prefix included, reaches the provider unchanged; the router
never rewrites keys, providers namespace internally. That is what lets a route be re-pointed without
touching a stored entry.
The contract is async throughout. IndexedDB and any future backend provider cannot be anything
else, and the web-storage providers lose nothing by returning an already-resolved promise. A
synchronous API would have made the eventual backend provider a breaking change for every call site.
The built-in locations
Section titled “The built-in locations”| Provider | Survives | Use it for |
|---|---|---|
FlyzeLocalStorageProvider | reload, tab close, browser restart | the default; device-scoped state |
FlyzeSessionStorageProvider | reload only — dies with the tab, not shared between tabs | anything that should not outlive the visit |
FlyzeIndexedDbProvider | as localStorage, with far more room | values too large for web storage’s few megabytes |
FlyzeMemoryStorageProvider | nothing — the instance only | an explicit “do not remember”, and specs |
All four are providedIn: 'root' and tree-shakable, so an unused one costs a consumer nothing.
Registering a route
Section titled “Registering a route”Each registration contributes exactly one route — the shape HTTP_INTERCEPTORS uses:
{ provide: FLYZE_STORAGE_ROUTES, multi: true, useFactory: () => ({ prefix: 'draft', provider: inject(FlyzeSessionStorageProvider) })}Prefixes match on segment boundaries: 'fyzUser' claims the key 'fyzUser' and everything under
'fyzUser.', but never 'fyzUserSomethingElse' — so a registrant does not have to remember a
trailing dot and a prefix cannot swallow a neighbouring section. When several routes match the
longest prefix wins; among equally long ones the last registered wins, so an app can override a route
contributed earlier.
Degrading instead of throwing
Section titled “Degrading instead of throwing”The web-storage providers wrap every access in the guard already proven in brick-generator-lib
(projects/brick-generator-lib/generators/core/brick-builder/src/lib/store/builder-layout.persistence.ts):
#withStorage<T>(use: (storage: Storage) => T): T | null { try { const storage = this.resolveStorage(); if (!storage) return null;
return use(storage); } catch { return null; }}Both halves earn their place: web storage is absent under SSR and in some test runners, and Safari in
private mode throws on access rather than returning null — which is why the storage is resolved
inside the try. A cosmetic feature must not be able to take an app’s bootstrap down, so every
failure degrades to “not remembered”, which is exactly the behavior before this module existed.
Corrupt JSON is treated as absent rather than thrown on every load, and a value JSON.stringify
cannot represent is dropped with a warning rather than persisted as the string "undefined".
FlyzeIndexedDbProvider follows the same rule: a missing indexedDB, a failed or blocked open, or a
rejected transaction resolves to null or a no-op, and a failed open is remembered so later calls
fail fast instead of retrying. It hands values to the structured clone algorithm rather than
serializing them, so Date, Map, Set and ArrayBuffer survive a round trip — and a corrupt-JSON
entry cannot exist for it at all.
Versioned keys
Section titled “Versioned keys”Keys are prefixed fyz.v1. by the providers that persist, so entries belonging to this library are
recognizable in DevTools and cannot collide with an app’s own storage. The version is a tool, not
decoration: a stored value always beats a default, so changing a default is invisible to everyone who
already has an entry. Bump it only when a stored value would mask a deliberate change — adding a
key or a field does not qualify, since an absent entry already falls back.
The memory provider stores raw keys: the prefix guards a shared namespace, and its map belongs to one instance.
Adding a location of your own
Section titled “Adding a location of your own”Implement FlyzeStorageProvider and register it on a route. Three rules make a provider well-behaved:
- Never throw. Degrade to
nullon read and to a no-op on write and delete. Callers are written against a contract that cannot fail, so a rejection surfaces somewhere that has no handling for it. - Keys arrive whole, prefix included. Namespace internally, as the built-ins do; do not assume the router stripped anything.
- Serialize your own state. If several keys share one stored entry, concurrent writes will read-modify-write over each other unless operations are queued.
The worked example — binding a section of keys to the signed-in user on a shared browser — is in guides/storing-client-state.
Risks and limits
Section titled “Risks and limits”- It cannot run before Angular bootstraps. The contract is async and the service comes from DI,
so an inline
index.htmlscript that applies a value before first paint has to read the prefixed key out oflocalStorageitself. Inside Angular, anAPP_INITIALIZERis early enough — Angular awaits all of them before first render. - Nothing stored here is protected. Client-side encryption is not on the table: a key shipped in
the bundle is readable by the same DevTools or XSS that reads the value, so it is obfuscation, not
protection. OWASP’s guidance is simply to keep sensitive data out of web storage. Note that the
in-house precedent is misleading —
AuthStorageService’sencrypt: trueis HS256 signing with a secret shipped inenvironment.default.js, which leaves the payload readable and forgeable. No pattern from it should be copied here. - Storage eviction. Safari/iOS may evict script-writable storage after roughly seven days of
non-use for a non-installed web app; installed PWAs are exempt, and
navigator.storage.persist()can request an exemption. Accepted: an evicted value falls back to a default. - Shared by everyone using the browser profile.
localStorageandIndexedDBare per-origin, not per-person. Anything user-specific needs a provider that scopes it — see the guide. - Dependency upgrades. The module imports nothing but
@angular/core— no Material, no CDK, no transloco, no@flyze/lib-core— so the usual breakage vectors do not apply.
Verification
Section titled “Verification”Covered by ng test lib-core-angular against real browser storage, with the fyz.v1. entries and
the IndexedDB store cleaned around every case — src/test.ts disables TestBed teardown, so a spec
that touches shared globals cleans up after itself:
- Web-storage providers: round-trip under
fyz.v1.keys; missing key and corrupt JSON both resolvenull;deleteremoves; aStorage.prototypespy that throws on read, write or delete resolvesnulland never rejects; a value JSON cannot represent warns and stores nothing; local and session providers do not see each other’s entries. IndexedDBprovider: round-trip; aDatesurvives the structured clone; absent key and delete resolvenull; anopenthat throws or errors degrades, and the failure is remembered rather than retried. A truly absentindexedDBglobal cannot be simulated in a real browser, so the same guard is exercised through a failingopen.- Memory provider: round-trip; nothing survives an instance; a caller mutating a value after writing or after reading it cannot reach what is stored; it never touches web storage.
- Router: unrouted keys reach the default; a key equal to the prefix is routed; a prefix does not
claim a key that merely starts with it; routes from separate
multiregistrations merge; longest prefix wins regardless of registration order; last registered wins among equally long prefixes; keys reach providers unchanged.