@stackra/storage
v2.0.0
Published
Unified KV storage layer for the Stackra framework — one IStorage contract with pluggable drivers (memory, null, localStorage, sessionStorage, IndexedDB via Dexie, AsyncStorage) and a MultipleInstanceManager for named instances.
Downloads
349
Maintainers
Readme
@stackra/storage
Unified KV storage layer for the Stackra framework.
One IStorage contract. Pluggable drivers per platform. A
MultipleInstanceManager for named instances so an app can host several
concurrent storage stores (preferences, session, offline, secure) each backed by
a different driver.
Why
Before this package, every feature package that needed to persist a small blob
of data (consent, scope, state, i18n, auth, …) shipped its own
LocalStorageXAdapter and AsyncStorageXAdapter implementations. That's eight
variants of the same code — and eight call sites to update when the shape
changes.
@stackra/storage replaces every one of them with one contract, one manager,
and platform-specific drivers registered per subpath.
Install
pnpm add @stackra/storageOptional peers (install only what you use):
# IndexedDB driver
pnpm add dexie
# React Native driver
pnpm add @react-native-async-storage/async-storageWeb setup
import { WebStorageModule } from "@stackra/storage/react";
@Module({
imports: [
WebStorageModule.forRoot({
default: "preferences",
stores: {
preferences: { driver: "localStorage", prefix: "app:prefs" },
session: { driver: "sessionStorage", prefix: "app:session" },
offline: { driver: "indexedDB", database: "app-offline" },
},
}),
],
})
export class AppModule {}Native setup
import { NativeStorageModule } from "@stackra/storage/native";
@Module({
imports: [
NativeStorageModule.forRoot({
default: "preferences",
stores: {
preferences: { driver: "asyncStorage", prefix: "app:prefs" },
},
}),
],
})
export class AppModule {}SecureStore driver (native)
@stackra/storage/native also ships an expoSecureStore driver backed by
expo-secure-store —
the canonical iOS Keychain / Android Keystore substrate for values that demand
encryption-at-rest (consent decisions, auth tokens, minor-data snapshots,
anything a GDPR / COPPA / regulated-market posture calls for).
expo-secure-store is declared as an optional peer — install it in the
parent app when you want the driver:
pnpm --filter <app> add expo-secure-storePlatform requirements
- iOS. No
Info.plistchanges required by default. SecureStore uses the keychain withWHEN_UNLOCKEDaccessibility, which is the driver's default and does not require biometric prompts. If a caller later opts into biometric-gated reads (per-keyrequireAuthentication: true), addNSFaceIDUsageDescriptiontoInfo.plist— otherwise, no purpose string is needed. - Android. Automatic — SecureStore uses
AndroidKeyStoreunder the hood on API 23+.
Wire pattern
Declare a secureStore-named instance in NativeStorageModule.forRoot(...)
alongside asyncStorage:
import { NativeStorageModule } from "@stackra/storage/native";
@Module({
imports: [
NativeStorageModule.forRoot({
default: "asyncStorage",
stores: {
asyncStorage: { driver: "asyncStorage", prefix: "app:" },
secureStore: { driver: "expoSecureStore", prefix: "app:secure:" },
},
}),
],
})
export class AppModule {}Any consumer of the storage manager can then reach it:
class ConsentStore {
constructor(
@Inject(STORAGE_MANAGER) private readonly storage: IStorageManager,
) {}
save(prefs: Record<string, boolean>): Promise<void> {
return this.storage.instance("secureStore").set("consent.v1", prefs);
}
}Fail-soft when the peer is missing
If expo-secure-store is not installed in the parent app the factory falls back
to a NullStore and logs a bootstrap warning — every read returns null and
every write is dropped. App boot survives; the misconfiguration surfaces in the
dev-tools console instead of a crash. Install the peer to turn the driver on.
Downstream consumer — @stackra/consent/native
SecureStoreConsentAdapter in @stackra/consent/native resolves the
secureStore-named instance via manager.instance("secureStore") — see
packages/frontend/consent/src/native/adapters/secure-store-consent.adapter.ts.
Wire the expoSecureStore driver above and set
NativeConsentModule.forRoot({ storage: "secureStore" }) to route consent
decisions through the encrypted substrate.
Known limitations
- No enumerate API. SecureStore does not expose "list every key", so the
driver maintains an in-memory tracked-keys set populated by every
set()and rehydrated on every successfulget().clear()sweeps exactly the keys this driver instance observed during its lifetime — rows written by an earlier session become visible once they've been read. - Async-only. Every operation is a bridge call across the RN JS ↔ native
boundary; latency is higher than
asyncStorage. PreferasyncStoragefor large / hot-path values, and reservesecureStorefor the compliance- sensitive subset.
Consume
import { Inject } from "@stackra/container";
import { STORAGE, type IStorage } from "@stackra/contracts";
class PreferencesService {
constructor(@Inject(STORAGE) private readonly storage: IStorage) {}
async loadTheme(): Promise<string> {
return (await this.storage.get<string>("theme")) ?? "light";
}
async saveTheme(theme: string): Promise<void> {
await this.storage.set("theme", theme);
}
}Need a different named instance? Inject the manager:
import { STORAGE_MANAGER, type IStorageManager } from "@stackra/contracts";
class OfflineCache {
constructor(
@Inject(STORAGE_MANAGER) private readonly storage: IStorageManager,
) {}
save<T>(key: string, value: T): Promise<void> {
return this.storage
.instance("offline")
.set(key, value, { ttlSeconds: 3600 });
}
}The IStorage contract
interface IStorage {
get<T>(key: string): Promise<T | null>;
set<T>(
key: string,
value: T,
options?: { ttlSeconds?: number },
): Promise<void>;
delete(key: string): Promise<void>;
clear(): Promise<void>;
has(key: string): Promise<boolean>;
keys(): Promise<string[]>;
}Every method returns a Promise. Sync backing stores (localStorage,
sessionStorage) wrap results in Promise.resolve(...) so consumer code never
branches on backing store. Missing / expired entries resolve to null.
React hooks
import { useStorage, useStorageValue } from "@stackra/storage/react";
function ThemeToggle() {
const [theme, setTheme] = useStorageValue<string>("theme", {
instance: "preferences",
initialValue: "light",
});
return (
<button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
Switch to {theme === "dark" ? "light" : "dark"}
</button>
);
}Testing
import {
createMockStorageManager,
MockStorage,
} from "@stackra/storage/testing";
const manager = createMockStorageManager();
await manager.instance().set("key", "value");
expect(await manager.instance().get("key")).toBe("value");Design decisions
- Promise-first
IStorage— one uniform shape across sync and async backing stores. Consumers never branch. - TTL at the driver level — every driver wraps values in a
{ v, e? }envelope. Manager stays value-agnostic. - Drivers register via
manager.extend(...)from subpath modules (WebStorageModule,NativeStorageModule). The core package is platform-agnostic; onlymemory+nulllive insrc/core. - Named instances — the manager hosts several
IStorageinstances side-by-side, each backed by its own driver. This is the sameMultipleInstanceManager<T>pattern@stackra/cache,@stackra/http, and@stackra/queuefollow.
License
MIT © Figentra L.L.C.
