diff --git a/.release-it.cjs b/.release-it.cjs index 3d9258e..0f1f258 100644 --- a/.release-it.cjs +++ b/.release-it.cjs @@ -107,7 +107,71 @@ const types = new Map([ const normalizeRepoUrl = url => url.replace(/^git\+/, "").replace(/\.git$/, ""); const repoUrl = pkg?.repository?.url ? normalizeRepoUrl(pkg.repository.url) : null; -module.exports = () => { +const breakingChangePattern = /\bBREAKING(?: |-)?CHANGE\b/i; + +function hasBreakingChange(commit) { + if (commit.breaking) { + return true; + } + + const type = String(commit.type || "").trim(); + + if (type.endsWith("!")) { + return true; + } + + if (typeof commit.header === "string" && /^\w+(?:\([^)]+\))?!:/.test(commit.header)) { + return true; + } + + if ( + commit.notes?.some(note => + [note.title, note.text].some(value => typeof value === "string" && breakingChangePattern.test(value)) + ) + ) { + return true; + } + + return typeof commit.footer === "string" && breakingChangePattern.test(commit.footer); +} + +function whatBump(commits, currentVersion = pkg.version) { + let isBreaking = false; + let isMinor = false; + let isPatch = false; + + for (const commit of commits) { + if (hasBreakingChange(commit)) { + isBreaking = true; + } + + const type = String(commit.type || "") + .trim() + .toLowerCase() + .replace(/!+$/, ""); + + if (["feat", "revert"].includes(type)) { + isMinor = true; + } + + if (["fix", "perf", "refactor", "ci"].includes(type)) { + isPatch = true; + } + } + + if (isBreaking) { + const currentMajor = Number.parseInt(String(currentVersion).replace(/^v/i, "").split(".")[0], 10); + + return {level: Number.isNaN(currentMajor) || currentMajor >= 1 ? 0 : 1}; + } + + if (isMinor) return {level: 1}; + if (isPatch) return {level: 2}; + + return null; +} + +const createReleaseConfig = () => { const contributors = getContributors(); return { @@ -167,34 +231,7 @@ module.exports = () => { contributors, }, - whatBump: commits => { - let isMajor = false; - let isMinor = false; - let isPatch = false; - - for (const commit of commits) { - if (commit.notes?.some(n => /BREAKING CHANGE/i.test(n.title || n.text || ""))) { - isMajor = true; - break; - } - - const type = (commit.type || "").toLowerCase(); - - if (type === "feat") { - isMinor = true; - } - - if (["fix", "perf", "refactor", "ci"].includes(type)) { - isPatch = true; - } - } - - if (isMajor) return {level: 0}; - if (isMinor) return {level: 1}; - if (isPatch) return {level: 2}; - - return null; - }, + whatBump, writerOpts: { headerPartial: "## 🚀 Release {{#if name}}`{{name}}` {{else}}{{#if @root.pkg}}`{{@root.pkg.name}}` {{/if}}{{/if}}v{{version}} ({{date}})\n\n", @@ -253,3 +290,5 @@ module.exports = () => { }, }; }; + +module.exports = Object.assign(createReleaseConfig, {whatBump}); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 11912ff..4910ab5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -83,12 +83,13 @@ Breaking changes: ## What affects versioning (SemVer policy) Version bumps are derived from commit history via `@release-it/conventional-changelog` and our policy: -- MAJOR (`x.0.0`) — any commit containing `BREAKING CHANGE` notes. -- MINOR (`0.y.0`) — `feat` and `revert` commits. +- MAJOR (`x.0.0`) — a breaking commit when the current version is `1.0.0` or newer. +- MINOR (`0.y.0`) — `feat` and `revert` commits, plus breaking commits while the package is on `0.x`. - PATCH (`0.0.z`) — `fix`, `perf`, `refactor`, `ci`. - No bump by default — `docs`, `test`, `chore`, `build` (these do not trigger an automatic release by themselves). Notes: +- Both the `type!:` syntax and a `BREAKING CHANGE:`/`BREAKING-CHANGE:` footer use the same breaking policy. - If multiple types are present, the highest applicable level wins. - Only visible types appear in the generated CHANGELOG; some meta types are hidden from release notes. @@ -174,4 +175,4 @@ Review process: If you discover a security issue, please do not open a public issue. Instead, email the maintainers at `addonbonedev@gmail.com` with the details. We will coordinate a fix and disclosure. -Thank you for contributing! \ No newline at end of file +Thank you for contributing! diff --git a/README.md b/README.md index 3a977c7..f1248bd 100644 --- a/README.md +++ b/README.md @@ -1,276 +1,775 @@ # @addon-core/storage -Typed storage for browser extensions with namespaces, atomic updates, encrypted values, bucket-style storage, and React bindings. +A typed, extension-first layer over `chrome.storage`. [![npm version](https://img.shields.io/npm/v/%40addon-core%2Fstorage.svg?logo=npm&style=for-the-badge)](https://www.npmjs.com/package/@addon-core/storage) [![npm downloads](https://img.shields.io/npm/dm/%40addon-core%2Fstorage.svg?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@addon-core/storage) [![CI](https://img.shields.io/github/actions/workflow/status/addon-stack/storage/ci.yml?style=for-the-badge)](https://github.com/addon-stack/storage/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](LICENSE.md) -## Why this package +Use concise functional helpers for one-off reads and writes, or create a reusable +provider for structured state, batch operations, lock-coordinated updates, +namespaces, encrypted values, and change subscriptions. Start without a schema +and add strict TypeScript types when your storage shape becomes stable. -`chrome.storage` is flexible, but it gets noisy quickly: +## Why @addon-core/storage -- storage keys are untyped and easy to mistype; -- namespaces need manual handling; -- read-modify-write flows are easy to break; -- encrypted values require extra boilerplate; -- feature state often ends up scattered across unrelated keys. +Browser extension state is often shared between popups, options pages, background +workers, and other extension contexts. The native Storage API provides the +persistence layer, but leaves typing, namespacing, safe read-modify-write flows, +encryption, and logical change handling to application code. -`@addon-core/storage` adds a small typed layer on top of `chrome.storage` so storage code stays predictable and easy to read. +`@addon-core/storage` keeps the native storage model while adding a consistent +API around it: -## Features - -- Simple API: `set`, `get`, `update`, `getAll`, `remove`, `clear`, `watch` -- Atomic `update()` for race-safe writes -- `local`, `session`, `sync`, and `managed` storage areas -- Namespaces for isolating module data -- `SecureStorage` with AES-GCM encryption -- `MonoStorage` for grouping related values under one top-level key -- React hook via `@addon-core/storage/react` +- start immediately with functional helpers and no required state interface; +- add strict key and value types without changing application code; +- use the same provider API across local, session, sync, managed, and secure storage; +- read and write multiple values through native batch operations; +- coordinate read-modify-write updates through Web Locks; +- observe individual keys with `watch()` or complete events with `subscribe()`; +- bind values to React through `@addon-core/storage/react`. ## Installation -### npm - ```bash npm i @addon-core/storage ``` -### pnpm +With pnpm or Yarn: ```bash pnpm add @addon-core/storage +yarn add @addon-core/storage ``` -### yarn +## Quick start -```bash -yarn add @addon-core/storage +For a one-off operation, call an area helper directly: + +```ts +import {storageLocal} from "@addon-core/storage"; + +await storageLocal("theme", "dark"); + +const theme = await storageLocal<"light" | "dark">("theme"); +// "light" | "dark" | undefined ``` -## Quick start +For a series of operations, create and reuse a typed provider: ```ts -import {Storage} from "@addon-core/storage"; +import {storageSync} from "@addon-core/storage"; -interface SessionState { - token?: string; +interface Settings { + language?: string; theme?: "light" | "dark"; } -const storage = Storage.Local(); -await storage.set("token", "abc123"); -await storage.set("theme", "dark"); +const settings = storageSync({ + namespace: "settings", +}); + +await settings.set({ + language: "uk", + theme: "dark", +}); + +const values = await settings.get(["theme", "language"] as const); +// Partial> +``` + +A state interface is optional. Without one, keys and values remain intentionally +loose. Add a state type when the shape stabilizes and key/value mistakes should +be caught by TypeScript. + +## Choose a storage area + +The storage area controls persistence, synchronization, quotas, and whether data +can be written. Prefer an area-specific helper so that intent stays visible in +the call site. + +| Area | Use it for | Helper | +| --- | --- | --- | +| `local` | Persistent data on the current device | `storageLocal()` | +| `session` | Temporary state for the current browser session | `storageSession()` | +| `sync` | Small user preferences synchronized between signed-in browsers | `storageSync()` | +| `managed` | Read-only policies provided by an administrator | `storageManaged()` | + +Use `local` when data does not need synchronization or session-only lifetime. +Use `sync` selectively because browser quotas are much stricter. The `managed` +area is controlled by the browser and is not writable by the extension. + +The general `storage()` helper uses `local` by default and accepts an explicit +`area` when the area must be selected dynamically: + +```ts +import {storage} from "@addon-core/storage"; + +const settings = storage({ + area: "sync", + namespace: "settings", +}); +``` + +`storageSecure()` also accepts `area` because one secure helper covers all +storage areas. + +## Functional API + +The functional API is the recommended entry point. + +### Available helpers + +| Helper | Result | +| --- | --- | +| `storage()` | Plain provider; `local` by default or the supplied `area` | +| `storageLocal()` | Plain `local` provider | +| `storageSession()` | Plain `session` provider | +| `storageSync()` | Plain `sync` provider | +| `storageManaged()` | Plain `managed` provider | +| `storageSecure()` | Encrypted provider; `local` by default or the supplied `area` | + +### Supported call forms + +Every helper supports the same one-shot and provider forms: -const token = await storage.get("token"); -const all = await storage.getAll(); +```ts +storageLocal(); // default StorageProvider +storageLocal({namespace: "settings"}); // configured StorageProvider +storageLocal("theme"); // single get +storageLocal("theme", "dark"); // single set +storageLocal(["theme", "language"]); // batch get ``` -## Typed storage without boilerplate +An object argument always means provider options. It is not interpreted as a +batch set. Create a provider for object-form writes: + +```ts +const local = storageLocal(); + +await local.set({ + language: "uk", + theme: "dark", +}); +``` + +### Typing helper calls + +The meaning of the generic follows the call form. + +For a single get or set, it describes the value: -Define your storage shape once: +```ts +const theme = await storageLocal<"light" | "dark">("theme"); + +await storageLocal("attempts", 3); +``` + +For a batch get, it describes the result map: ```ts -interface UserSettings { +const selected = await storageSync<{ + language?: string; theme?: "light" | "dark"; - language?: "en" | "uk"; - shortcutsEnabled?: boolean; -} +}>(["theme", "language"]); ``` -Create a typed storage instance for the `sync` area: +For provider creation, it describes the complete storage state: + +```ts +const settings = storageSync({ + namespace: "settings", +}); + +const theme = await settings.get("theme"); +// "light" | "dark" | undefined +``` + +Without a generic, one-shot reads use `any` and providers accept arbitrary string +keys. Runtime validation still applies: top-level `undefined` is rejected, +reserved key characters still throw, and browser serialization rules are +unchanged. + +### Options and provider reuse + +Common provider options are: + +- `namespace` to isolate logical keys; +- `locker` to replace the default Web Locks implementation; +- `key` to group the state in one physical MonoStorage bucket; +- `area` on `storage()` and `storageSecure()`; +- `secureKey` on `storageSecure()`. + +```ts +const popup = storageLocal({ + key: "popup", + namespace: "ui", +}); +``` + +Every helper invocation creates a new provider. Reuse the returned provider for a +series of operations, especially when using namespaces, a custom locker, or +secure storage. Each one-shot `storageSecure()` call creates a provider and +repeats the SHA-256 digest and AES key import. + +## Class API + +The class API exposes the same providers and methods. Use it when class factories +fit the surrounding architecture better. + +### Storage ```ts import {Storage} from "@addon-core/storage"; -const settings = Storage.Sync({namespace: "settings"}); +const local = Storage.Local(); +const session = Storage.Session(); +const sync = Storage.Sync({namespace: "settings"}); +const managed = Storage.Managed(); ``` -Now all operations are typed: +`Storage.make()` accepts an explicit `area`, and the constructor is available for +direct composition: + +```ts +const dynamic = Storage.make({area: "sync"}); +const direct = new Storage({area: "local"}); +``` + +### SecureStorage + +```ts +import {SecureStorage} from "@addon-core/storage"; + +interface AuthState { + accessToken?: string; + refreshToken?: string; +} + +const auth = SecureStorage.Session({ + namespace: "auth", + secureKey: "AppSecret", +}); +``` + +`SecureStorage` provides the same `Local`, `Session`, `Sync`, `Managed`, and +`make` factories as `Storage`. + +### MonoStorage + +Pass `key` to a package factory to receive a `MonoStorage` provider backed by one +physical storage entry: + +```ts +interface PopupState { + search?: string; + selectedTab?: "overview" | "history"; +} + +const popup = Storage.Local({ + key: "popup", +}); +``` + +The `MonoStorage` class is also exported for custom provider composition, but the +`key` factory option is the concise path for normal use. + +## Reading and writing + +Every provider exposes one consistent API regardless of its area or physical +storage model. + +### Read values ```ts -await settings.set("theme", "dark"); const theme = await settings.get("theme"); +const selected = await settings.get(["theme", "language"] as const); +const all = await settings.getAll(); +``` + +A missing single key resolves to `undefined`. Missing keys are omitted from batch +and `getAll()` results. An empty batch returns an empty object without native I/O. + +### Write values + +```ts +await settings.set("theme", "dark"); + +await settings.set({ + language: "uk", + theme: "dark", +}); +``` + +The object overload uses one native `storage.set()` for plain and secure storage. +`MonoStorage` performs one locked update of its physical bucket. + +Both `set()` forms reject `undefined` before encryption, locking, or native I/O. +Use `remove()` or return `undefined` from an `update()` updater to delete data. +`null` remains a valid value. + +The object form must be a plain object. Arrays, functions, dates, maps, and class +instances are rejected instead of being interpreted as key maps. + +`set()` means “perform this write.” It does not skip a write merely because the +logical value is deeply equal. Change observers still filter logically equal +old and new values. + +### Remove values + +```ts await settings.remove("language"); +await settings.remove(["language", "theme"]); +await settings.clear(); ``` -## Atomic updates +Batch remove uses one native removal call after acquiring the relevant locks. +`clear()` removes only the physical keys owned by that provider; its exact scope +is described under [Namespaces and data scope](#namespaces-and-data-scope). + +## Safe updates + +Use `update()` whenever the next value depends on the previous value. A separate +`get()` followed by `set()` can lose concurrent changes made by another extension +context. -If the next value depends on the previous one, use `update()` instead of `get()` + `set()`. -This is especially useful in browser extensions, where the same storage value can -be updated from different contexts. Atomic updates keep each read-modify-write -operation consistent, so one context does not overwrite changes made by another. +### Single-key update ```ts -interface CounterState { +interface UsageState { installCount?: number; } -const storage = Storage.Local(); +const usage = storageLocal({ + namespace: "usage", +}); -await storage.update("installCount", prev => (prev ?? 0) + 1); +const count = await usage.update( + "installCount", + previous => (previous ?? 0) + 1 +); ``` -Use it for extension state that can be touched from more than one context: +The updater may be synchronous or asynchronous. Returning `undefined` removes the +key. By default, deeply equal results skip the physical write. -- install or usage counters; -- retry state shared by background and UI; -- popup or options toggles; -- queue metadata for background jobs; -- any read-modify-write flow shared across extension contexts. +### Batch update -### With timeout or abort signal +The array overload locks the selected keys in a stable order, reads one snapshot, +and applies one returned patch: ```ts -const controller = new AbortController(); +const next = await settings.update( + ["theme", "language"] as const, + previous => ({ + language: previous.language ?? "en", + theme: previous.theme === "dark" ? "light" : "dark", + }) +); +``` -await storage.update( - "installCount", - prev => (prev ?? 0) + 1, +A selected key omitted from the patch stays unchanged. An own patch property set +to `undefined` removes that key. Returning an unselected key throws before any +write. The resolved object is the final snapshot of the selected keys, with +missing and removed keys omitted. + +### Equality comparison + +A single-key comparer receives the previous and proposed values: + +```ts +await settings.update( + "theme", + () => "dark", { - signal: controller.signal, - timeout: 500, + compare: (previous, next) => previous === next, } ); ``` -### Custom compare +A batch comparer receives the complete previous and proposed snapshots and makes +one decision for the whole batch: + +```ts +await settings.update( + ["theme", "language"] as const, + previous => ({ + language: previous.language ?? "en", + theme: "dark", + }), + { + compare: (previous, next) => + previous.theme === next.theme && + (previous.language ?? "en") === (next.language ?? "en"), + } +); +``` + +Returning `true` skips the update and resolves to the previous value or snapshot. +Returning `false` applies the explicit patch, including defined patch values that +are deeply equal to their stored values. It does not guarantee a logical +notification: observers filter equal old and new values. + +Without a custom batch comparer, each explicit patch value is compared deeply. +Only changed values are written, only existing requested keys are removed, and +omitted keys remain untouched. -`update()` skips writes when the returned value is equal to the previous value. -Pass `compare` when a specific update needs custom equality rules. +### Lock timeout and cancellation ```ts -await storage.update( - "settings", - prev => ({...prev, theme: "dark"}), +const controller = new AbortController(); + +await settings.update( + ["theme", "language"] as const, + previous => ({ + ...previous, + theme: "dark", + }), { - compare: (prev, next) => prev?.version === next?.version, + signal: controller.signal, + timeout: 500, } ); ``` -If `compare` returns `true`, the values are treated as equal and no write is made. -This also means no `watch()` callbacks are triggered for that update. Use -`compare: () => false` when you need to force a write and notification. +`signal` and `timeout` apply while a lock request is queued. Once a lock has been +granted, aborting the signal does not cancel the running updater. For a batch, +the timeout applies to each selected lock acquisition rather than one deadline +for the entire operation. + +Locks coordinate only package operations that use the same lock names. Direct +`set()` calls on plain or secure storage and raw `chrome.storage` writes do not +participate, so locking is not a database transaction. -### Important note +### Mixed writes and deletions -Atomic operations rely on the Web Locks API. +For `Storage` and `SecureStorage`, a batch patch that both writes and deletes +requires one native `set()` followed by one native `remove()`. It can therefore +emit two native change events and invoke `subscribe()` twice. `MonoStorage` +changes one physical bucket and emits one bucket event. -- `update()` uses locking for safe writes; -- `remove()` and `clear()` are lock-aware too; -- `set()` and `get()` still work without Web Locks; -- if Web Locks are unavailable, atomic operations will throw. +If the set phase succeeds and the remove phase fails, `update()` rejects with +`StoragePartialUpdateError`. Its `appliedSetKeys` and +`attemptedRemoveKeys` contain logical keys, and `cause` preserves the native +removal error. No rollback is attempted. Extension context termination between +the two native calls can leave the same partial state without a JavaScript error. -## Storage areas +### Custom locker + +Providers use `WebLockManager` by default. Supply a `StorageLocker` when another +coordination mechanism is required: ```ts -import {Storage} from "@addon-core/storage"; +import {storageLocal, type StorageLocker} from "@addon-core/storage"; + +const locker: StorageLocker = { + async request(name, task) { + return await task(); + }, +}; -const local = Storage.Local<{draft?: string}>(); -const session = Storage.Session<{popupOpen?: boolean}>(); -const sync = Storage.Sync<{theme?: string}>(); -const managed = Storage.Managed<{policyEnabled?: boolean}>(); +const storage = storageLocal<{count?: number}>({ + locker, + namespace: "custom-locking", +}); ``` -## Namespaces +Reads do not require Web Locks. Single and batch `update()`, `remove()`, +`clear()`, and MonoStorage mutations are lock-coordinated. Direct plain and +secure `set()` calls do not acquire a lock. + +## Watching changes + +### Watch individual keys -Use namespaces when different modules may use the same key names. +`watch()` is key-oriented. A function watcher runs once for every changed +logical key: ```ts -const auth = Storage.Local<{token?: string}>({namespace: "auth"}); -const ui = Storage.Local<{token?: string}>({namespace: "ui"}); +const unsubscribe = settings.watch((next, previous, key) => { + console.log(key, previous, "->", next); +}); ``` -These storage instances stay isolated even if the key name is the same. +An object watcher handles only selected keys: -## Secure storage +```ts +const unsubscribe = settings.watch({ + theme(next, previous) { + console.log("theme", previous, "->", next); + }, + language(next, previous) { + console.log("language", previous, "->", next); + }, +}); +``` -`SecureStorage` encrypts values before writing them to `chrome.storage`. +### Subscribe to complete events + +`subscribe()` receives one logical change map for each matching package-level +event: ```ts -import {SecureStorage} from "@addon-core/storage"; +const unsubscribe = settings.subscribe(changes => { + if (changes.theme) { + console.log( + "theme", + changes.theme.oldValue, + "->", + changes.theme.newValue + ); + } + + if (changes.language) { + console.log( + "language", + changes.language.oldValue, + "->", + changes.language.newValue + ); + } +}); +``` + +| Method | Delivery | +| --- | --- | +| `watch()` | One callback per changed logical key | +| `subscribe()` | One callback with the event’s logical change map | + +Both APIs filter by area, namespace, physical key shape, and deep equality. +`SecureStorage` decrypts values before delivery. `MonoStorage` expands its +physical bucket change into logical key changes. A callback is not invoked when +nothing changed logically. + +### Delivery order and unsubscribe + +Events are formatted in FIFO order for each registration, so an earlier event’s +callback is invoked before a later event’s callback. Returned callback promises +are observed for rejection but are not awaited; asynchronous callbacks may +overlap and cannot block later events. + +The returned unsubscribe function is idempotent. It removes the native listener, +clears queued events, and prevents delivery after an in-progress format or +decrypt step finishes. A `watch()` handler can unsubscribe during a multi-key +event, preventing later handlers in the same fan-out from running. + +### Error behavior + +Handle expected application failures inside the callback: + +```ts +const unsubscribe = settings.subscribe(async changes => { + try { + await sendChangesToServer(changes); + } catch (error) { + console.error("Could not synchronize storage changes", error); + } +}); +``` + +A synchronous callback throw or rejected callback promise is surfaced as an +uncaught asynchronous exception. It does not close the registration: sibling +`watch()` handlers and future events continue to run. + +A corruption, decryption, or internal formatting failure is stricter. The +affected registration is disposed, its queued events are cleared, and the +current event is not delivered partially. The error is then surfaced +asynchronously. A `try/catch` around registration cannot catch an error produced +by a later native event. Separately registered listeners remain independent. + +For `SecureStorage`, one corrupted matching entry in a multi-key native event +rejects that entire logical event, including valid sibling changes in the same +provider scope. + +
+Background context behavior after an internal listener failure + +In a persistent Manifest V2 background page, the failed registration stays +disposed until the page reloads. A Manifest V3 service worker registers it again +when the worker starts later. Repeated corrupted input can therefore fail again +after subsequent worker wake-ups. + +
+ +## Namespaces and data scope + +Use namespaces when modules may use the same logical key names: + +```ts +const auth = storageLocal<{token?: string}>({ + namespace: "auth", +}); + +const analytics = storageLocal<{token?: string}>({ + namespace: "analytics", +}); +``` + +The providers use different physical keys and do not observe, enumerate, or +clear each other’s values. + +A plain `Storage` provider without a namespace owns every one-segment plain key +in its area. Its `getAll()` and `clear()` exclude namespaced and secure entries, +but they do include unnamespaced MonoStorage buckets. A namespaced provider owns +keys with its exact namespace, including MonoStorage buckets created in that +same scope. + +MonoStorage has no extra physical tag: its bucket is an ordinary logical key in +the underlying plain or secure provider. A broad provider with the same area and +namespace can therefore enumerate or clear that bucket. The MonoStorage provider +itself reads, observes, and clears only its selected bucket. + +The colon (`:`) is reserved as the physical key separator. Namespaces and +top-level logical keys used by `Storage` or `SecureStorage` cannot contain it. +The physical MonoStorage bucket `key` follows the same rule, while logical field +names inside the bucket may contain colons. + +Keys that do not match a provider’s exact codec are ignored by its `getAll()`, +events, and `clear()`. + +## SecureStorage + +`SecureStorage` encrypts each logical value with AES-GCM before writing it to +native storage. + +The recommended functional form is: + +```ts +import {storageSecure} from "@addon-core/storage"; interface AuthState { accessToken?: string; refreshToken?: string; } -const authStorage = SecureStorage.Local({ +const auth = storageSecure({ + area: "local", + namespace: "auth", + secureKey: "AppSecret", +}); + +await auth.set("accessToken", "jwt-token"); +const token = await auth.get("accessToken"); +``` + +The class factories provide the same behavior: + +```ts +import {SecureStorage} from "@addon-core/storage"; + +const auth = SecureStorage.Local({ namespace: "auth", secureKey: "AppSecret", }); +``` + +Use the same stable `secureKey` whenever the values must be decrypted later. +The provider hashes it with SHA-256 and imports the result as an AES-GCM key. +The secure key is application-provided; this package does not provide a +hardware-backed secret store. + +### Physical key format + +Secure keys always have three segments: -await authStorage.set("accessToken", "jwt-token"); -const token = await authStorage.get("accessToken"); +```text +secure:: ``` -Use it for tokens, sensitive flags, or other small private values. +Examples: -## MonoStorage +```text +secure::theme +secure:auth:accessToken +``` -`MonoStorage` is useful when one feature should live under a single top-level storage key. +This distinguishes unnamespaced secure data from plain +`storageLocal({namespace: "secure"})` data such as `secure:theme`. The +ciphertext format remains `iv:ciphertext`. -For example, keeping popup state together: +### Corrupted values and recovery -```ts -import {Storage} from "@addon-core/storage"; +A present empty, non-string, or undecipherable value throws +`StorageCorruptionError`. Its `provider` and `key` identify the logical entry, +and `cause` preserves the format or decryption error. +One corrupted selected key rejects the whole batch get. One corrupted owned key +rejects `getAll()`. Reads do not silently fall back to defaults. + +Known corrupted SecureStorage entries can be recovered without decrypting the +old value: + +- `set()` replaces a known key; +- `remove()` deletes known keys; +- `clear()` enumerates and removes all keys owned by that secure provider. + +`remove()` and `clear()` never decrypt the stored ciphertext. + +## MonoStorage + +`MonoStorage` groups a feature’s logical state under one physical storage key. + +```ts interface PopupState { + filters?: string[]; search?: string; selectedTab?: "overview" | "history"; - filters?: string[]; } -const popup = Storage.Local({key: "popup"}); -``` +const popup = storageLocal({ + key: "popup", +}); -Then use it like a regular storage instance: +await popup.set({ + search: "open tabs", + selectedTab: "overview", +}); -```ts -await popup.set("search", "open tabs"); -await popup.update("filters", prev => [...(prev ?? []), "pinned"]); +await popup.update( + "filters", + previous => [...(previous ?? []), "pinned"] +); const state = await popup.getAll(); ``` -This keeps related values grouped and easier to manage. `MonoStorage.set()` updates -one field inside the grouped object, so it performs the same locked bucket update -as `MonoStorage.update()` instead of writing a separate top-level storage key. +A MonoStorage mutation is a locked read-modify-write of the bucket. Single and +batch sets perform one bucket update and write even when supplied logical values +are deeply equal. A batch update changes the physical bucket once, so one native +event becomes one logical `subscribe()` callback. -## Watching changes +A missing bucket is treated as empty. A present non-plain-object bucket is +corrupted and throws `StorageCorruptionError` instead of being overwritten as an +empty object. Removing the last logical field removes the physical bucket. -Listen to all keys: +### Corruption recovery -```ts -const unsubscribe = settings.watch((next, prev, key) => { - console.log("changed", key, {prev, next}); -}); -``` +Recovery differs because SecureStorage stores values independently while +MonoStorage must decode its complete bucket before changing one logical field. -Or subscribe only to specific keys: +| Provider | `set()` | `remove()` | `clear()` | +| --- | --- | --- | --- | +| `SecureStorage` | Replaces a known ciphertext without reading it | Removes known physical entries without decrypting | Removes all owned physical entries without decrypting | +| `MonoStorage` over `Storage` | Cannot replace one field in a corrupted bucket | Cannot remove one field from a corrupted bucket | Removes the physical bucket directly | +| `MonoStorage` over `SecureStorage` | Cannot decrypt and update a corrupted bucket | Cannot decrypt and update a corrupted bucket | Removes the encrypted physical bucket without decrypting | -```ts -const unsubscribe = settings.watch({ - theme(next, prev) { - console.log("theme changed", prev, "->", next); - }, - language(next, prev) { - console.log("language changed", prev, "->", next); - }, -}); -``` +Use `clear()` or an exact native removal to recover a corrupted MonoStorage +bucket. Its logical `set()`, `update()`, and `remove()` operations intentionally +fail instead of coercing damaged data into a new bucket. ## React -The React adapter is available via `@addon-core/storage/react`. +The React adapter is available through `@addon-core/storage/react`. ```tsx import {useStorage} from "@addon-core/storage/react"; export function ThemeToggle() { - const [theme, setTheme] = useStorage<"light" | "dark">("theme", "light"); + const [theme, setTheme] = useStorage<"light" | "dark">( + "theme", + "light" + ); return (