Skip to content

Client storage

@flyze/lib-core-angular 1.0.0-alpha.34· latest

FlyzeStorageService 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 null
await 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.

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" .-> LS

Every 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.

ProviderSurvivesUse it for
FlyzeLocalStorageProviderreload, tab close, browser restartthe default; device-scoped state
FlyzeSessionStorageProviderreload only — dies with the tab, not shared between tabsanything that should not outlive the visit
FlyzeIndexedDbProvideras localStorage, with far more roomvalues too large for web storage’s few megabytes
FlyzeMemoryStorageProvidernothing — the instance onlyan explicit “do not remember”, and specs

All four are providedIn: 'root' and tree-shakable, so an unused one costs a consumer nothing.

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.

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.

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.

Implement FlyzeStorageProvider and register it on a route. Three rules make a provider well-behaved:

  • Never throw. Degrade to null on 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.

  • It cannot run before Angular bootstraps. The contract is async and the service comes from DI, so an inline index.html script that applies a value before first paint has to read the prefixed key out of localStorage itself. Inside Angular, an APP_INITIALIZER is 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’s encrypt: true is HS256 signing with a secret shipped in environment.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. localStorage and IndexedDB are 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.

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 resolve null; delete removes; a Storage.prototype spy that throws on read, write or delete resolves null and never rejects; a value JSON cannot represent warns and stores nothing; local and session providers do not see each other’s entries.
  • IndexedDB provider: round-trip; a Date survives the structured clone; absent key and delete resolve null; an open that throws or errors degrades, and the failure is remembered rather than retried. A truly absent indexedDB global cannot be simulated in a real browser, so the same guard is exercised through a failing open.
  • 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 multi registrations merge; longest prefix wins regardless of registration order; last registered wins among equally long prefixes; keys reach providers unchanged.