From dff46a71fcbdf0951c20e9e32c9c04c134a07129 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:16:52 +0300 Subject: [PATCH 1/8] feat!: add batch storage operations and subscriptions - add multi-key overloads for get, set, and update - add subscribe with FIFO event formatting and keep watch as a per-key projection - align Storage, SecureStorage, and MonoStorage locking, corruption, recovery, and no-op semantics - introduce strict physical-key decoding and explicit SecureStorage legacy migration - cover runtime, type-level, locking, codec, React, and batch-planner behavior BREAKING CHANGE: set rejects undefined, and flat keys or namespaces containing ":" are rejected. Unnamespaced SecureStorage keys now use secure:: instead of secure:, so legacy ciphertext requires explicit raw migration. Listener data or corruption errors now terminate the affected registration and surface asynchronously. --- README.md | 308 +++++++++- package.json | 2 +- src/LockManager.test.ts | 258 ++++++-- src/LockManager.ts | 61 +- src/adapters/react/index.ts | 7 +- src/adapters/react/useStorage.test.ts | 20 + src/adapters/react/useStorage.ts | 35 +- src/batch.test.ts | 156 +++++ src/batch.ts | 62 ++ src/constants.ts | 1 + src/errors.ts | 31 + src/index.ts | 10 + src/providers/AbstractStorage.ts | 425 ++++++++++--- src/providers/MonoStorage.test.ts | 370 ++++++++++- src/providers/MonoStorage.ts | 355 +++++++++-- src/providers/SecureStorage.test.ts | 680 ++++++++++++++++++++- src/providers/SecureStorage.ts | 246 ++++++-- src/providers/Storage.test.ts | 848 +++++++++++++++++++++++++- src/providers/Storage.ts | 33 +- src/providers/index.ts | 2 + src/types.ts | 42 +- src/utils.ts | 113 ++++ src/watch.test.ts | 161 +++++ src/watch.ts | 52 ++ tests/batch.types.ts | 183 ++++++ tests/factories.types.ts | 79 +++ tests/helpers/async.ts | 38 ++ tests/helpers/webLocks.ts | 98 +++ tests/jest.globals.d.ts | 22 +- tests/jest.storage.setup.ts | 163 +++-- 30 files changed, 4418 insertions(+), 443 deletions(-) create mode 100644 src/batch.test.ts create mode 100644 src/batch.ts create mode 100644 src/constants.ts create mode 100644 src/errors.ts create mode 100644 src/utils.ts create mode 100644 src/watch.test.ts create mode 100644 src/watch.ts create mode 100644 tests/batch.types.ts create mode 100644 tests/factories.types.ts create mode 100644 tests/helpers/async.ts create mode 100644 tests/helpers/webLocks.ts diff --git a/README.md b/README.md index 3a977c7..a61c21f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # @addon-core/storage -Typed storage for browser extensions with namespaces, atomic updates, encrypted values, bucket-style storage, and React bindings. +Typed storage for browser extensions with namespaces, lock-coordinated updates, encrypted values, bucket-style +storage, and React bindings. [![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) @@ -21,8 +22,9 @@ Typed storage for browser extensions with namespaces, atomic updates, encrypted ## Features -- Simple API: `set`, `get`, `update`, `getAll`, `remove`, `clear`, `watch` -- Atomic `update()` for race-safe writes +- Typed single-key and batch overloads for `get`, `set`, and `update` +- Lock-coordinated `update()` for race-safe single-key and batch writes +- Per-key and per-event subscriptions through `watch()` and `subscribe()` - `local`, `session`, `sync`, and `managed` storage areas - Namespaces for isolating module data - `SecureStorage` with AES-GCM encryption @@ -95,12 +97,125 @@ const theme = await settings.get("theme"); await settings.remove("language"); ``` -## Atomic updates +## Batch operations + +Pass an array of keys or an object of values to the regular `get`, `set`, and +`update` methods when several values should be handled together. The overloads +preserve the same key and value types as their single-key forms. + +Read selected keys with one native storage request: + +```ts +const values = await settings.get(["theme", "language"] as const); + +console.log(values.theme); +console.log(values.language); +``` + +Missing keys are omitted from the returned object. An empty key list returns an +empty object. + +Write several values with one native `storage.set()` call: + +```ts +await settings.set({ + theme: "dark", + language: "uk", + shortcutsEnabled: true, +}); +``` + +Both forms of `set()` reject `undefined` before encryption, locking, or native +I/O. Use `remove(keys)` or an `update()` patch when keys should be deleted. The +object form must be a plain object; arrays, class instances, and other values are +rejected instead of being interpreted as key maps. + +`set()` means "perform this write" and does not elide deeply equal values. +`update()` is the conditional API: its comparer decides whether a physical write +is needed. `watch()` and `subscribe()` still report only logical value changes. + +When values change, this single native write produces one `storage.onChanged` +event containing the changed keys. Use `subscribe()` when the package-level +subscriber should also run once for that event. + +### Lock-coordinated batch updates + +The array overload of `update()` locks the selected keys, reads one snapshot, +and applies the returned patch without races against other lock-aware package +operations: + +```ts +const next = await settings.update( + ["theme", "language", "shortcutsEnabled"] as const, + prev => ({ + theme: prev.theme === "dark" ? "light" : "dark", + language: prev.language ?? "en", + }) +); + +console.log(next.theme); +``` + +The updater may be synchronous or asynchronous. A selected key omitted from the +patch stays unchanged. An own property whose value is `undefined` removes that +key. Returning a key that was not included in the key list throws before any +write is made. The returned object is the final snapshot of the selected keys; +removed keys are omitted. + +Lock acquisition and equality checks can be configured for the whole batch: + +```ts +const controller = new AbortController(); + +await settings.update( + ["theme", "language"] as const, + prev => ({ + theme: "dark", + language: prev.language ?? "en", + }), + { + signal: controller.signal, + timeout: 500, + compare: { + theme: (prev, next) => prev === next, + }, + } +); +``` + +Keys use deep equality by default, just like `update()`. A key-specific +`compare` function returns `true` to treat that logical key as unchanged. For +`MonoStorage`, another changed key may still cause the shared bucket to be +written. + +The `timeout` applies to each selected lock acquisition, not as one deadline +for the whole batch. Direct `set()` calls and raw native storage writes do not +participate in these locks. + +For regular `Storage` and `SecureStorage`, a patch that both writes values and +deletes keys requires one native `set()` followed by one native `remove()`. +That mixed operation therefore emits two native change events. If one event is +required, keep writes and deletions out of the same patch, or use `MonoStorage`, +where the logical values share one physical bucket. Consequently, +`subscribe()` can run twice for a mixed regular or secure update, while the +same `MonoStorage` update changes its bucket once and produces one callback. + +This two-phase operation is not a transaction. If `set()` completes and the +following `remove()` fails, the promise rejects with +`StoragePartialUpdateError`. Its `appliedSetKeys` and `attemptedRemoveKeys` +arrays contain logical keys, while `cause` contains the native removal error. +Only regular `Storage` and `SecureStorage` can throw this error; `MonoStorage` +commits the logical batch through one physical bucket operation. No rollback is +attempted. Extension context termination between the two native calls can leave +the same torn state without an observable JavaScript exception. + +## Lock-coordinated updates 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. +be updated from different contexts. Lock-coordinated updates keep each +read-modify-write operation consistent with other package operations that use +the same locks, so one context does not overwrite changes made by another. ```ts interface CounterState { @@ -152,16 +267,26 @@ await storage.update( 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. +`compare: () => false` when you need to force a physical write. It cannot force +a logical notification: `storage.onChanged` contains no provenance for the +write, and equal logical values are filtered by observers. ### Important note -Atomic operations rely on the Web Locks API. +Lock-coordinated operations rely on the Web Locks API. -- `update()` uses locking for safe writes; +- both overloads of `update()` use locking for safe writes; +- `Storage` and `SecureStorage` acquire selected key locks in a stable order, + while `MonoStorage` locks its single physical bucket; - `remove()` and `clear()` are lock-aware too; -- `set()` and `get()` still work without Web Locks; -- if Web Locks are unavailable, atomic operations will throw. +- reads work without Web Locks; +- direct `Storage` and `SecureStorage` writes through either `set()` overload do + not require Web Locks, while `MonoStorage` locks these writes because changing + a logical field is a read-modify-write of its bucket; +- if Web Locks are unavailable, lock-coordinated operations will throw. + +`signal` and `timeout` apply only while a lock request is queued. Once the lock +has been granted, aborting the signal does not cancel the updater. ## Storage areas @@ -174,6 +299,13 @@ const sync = Storage.Sync<{theme?: string}>(); const managed = Storage.Managed<{policyEnabled?: boolean}>(); ``` +The `managed` area is read-only. Both `get()` overloads and `getAll()` can read +managed policy values. A mutation rejects when it reaches a native managed-area +write. A package-level no-op may still resolve because no native write is made; +examples include `set({})`, `update([])`, `remove([])`, and an `update()` whose +result compares equal to the current value. Do not interpret a resolved no-op as +write access to managed storage. + ## Namespaces Use namespaces when different modules may use the same key names. @@ -185,6 +317,15 @@ const ui = Storage.Local<{token?: string}>({namespace: "ui"}); These storage instances stay isolated even if the key name is the same. +The colon (`:`) is reserved as the separator in physical storage keys. +Namespaces and top-level logical keys used by `Storage` or `SecureStorage` +therefore cannot contain `:`. The physical bucket key passed as `{key}` to a +package factory follows the same rule, while logical field names inside a +`MonoStorage` bucket may contain `:`. Entries previously written with a colon in +a restricted component are not migrated or removed automatically; clean them up +by their exact physical key through the native `chrome.storage.` API before +using the provider. + ## Secure storage `SecureStorage` encrypts values before writing them to `chrome.storage`. @@ -208,6 +349,56 @@ const token = await authStorage.get("accessToken"); Use it for tokens, sensitive flags, or other small private values. +Secure physical keys always have three segments: + +```text +secure:: +``` + +For example, an unnamespaced `theme` key is stored as `secure::theme`, while an +`accessToken` in the `auth` namespace is stored as `secure:auth:accessToken`. +This fixed shape keeps unnamespaced secure data separate from plain +`Storage({namespace: "secure"})`, whose corresponding key is `secure:theme`. + +Older namespaced SecureStorage keys already use the current shape and require no +migration. Older unnamespaced keys used `secure:key` and are not read, migrated, +or removed automatically. Migrate only explicitly known legacy SecureStorage +entries through the matching native storage area: + +```ts +const legacyKey = "secure:theme"; +const currentKey = "secure::theme"; +const values = await chrome.storage.local.get([legacyKey, currentKey]); + +if (Object.prototype.hasOwnProperty.call(values, currentKey)) { + throw new Error(`Refusing to overwrite ${currentKey}`); +} + +if (Object.prototype.hasOwnProperty.call(values, legacyKey)) { + await chrome.storage.local.set({[currentKey]: values[legacyKey]}); + await chrome.storage.local.remove(legacyKey); +} +``` + +The ciphertext can be copied without decryption because the physical key is not +used as AES-GCM additional authenticated data. Do not migrate `secure:key` by +pattern alone: the same legacy key may belong to plain +`Storage({namespace: "secure"})`. + +`SecureStorage` treats a present empty, non-string, or undecipherable value as +corruption and throws the exported `StorageCorruptionError`. Its `provider` and +`key` identify the failed logical entry, and `cause` preserves the format or +decryption error. A corrupted key rejects `get()`, a selected-key batch `get()`, +and the whole `getAll()` result; reads do not silently fall back to defaults. +This can intentionally fail an extension boot path that depends on `getAll()`. + +Direct `SecureStorage` operations can recover known corrupted data without +decrypting it: overwrite the key with `set()`, delete a known key with `remove()`, +or delete the provider contents with `clear()`. `remove()` and `clear()` never +decrypt stored ciphertext. `getAll()`, events, and `clear()` only consider keys +with the exact current physical shape; malformed and legacy keys require raw +cleanup. + ## MonoStorage `MonoStorage` is useful when one feature should live under a single top-level storage key. @@ -229,15 +420,36 @@ const popup = Storage.Local({key: "popup"}); Then use it like a regular storage instance: ```ts -await popup.set("search", "open tabs"); +await popup.set({ + search: "open tabs", + selectedTab: "overview", +}); await popup.update("filters", prev => [...(prev ?? []), "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. +This keeps related values grouped and easier to manage. `MonoStorage.set()` +performs one locked bucket update and writes even when the supplied logical value +is deeply equal. Its batch writes and updates also change that bucket only once. +A present non-plain-object bucket is treated as corrupted rather than as an empty +bucket. `clear()` can still remove it without decoding it. + +### Corruption recovery + +Recovery differs because `SecureStorage` stores keys independently, while +`MonoStorage` must decode its complete bucket before changing one logical field: + +| Provider | `set()` | `remove()` | `clear()` | +| --- | --- | --- | --- | +| `SecureStorage` | Recovers a known key by replacing its ciphertext without reading the old value. | Recovers known keys by removing their physical entries without decrypting them. | Enumerates matching physical keys and removes them without decrypting their values. | +| `MonoStorage` over `Storage` | Cannot recover a corrupted bucket because changing one field first reads and decodes the bucket. | Cannot remove a logical field from a corrupted bucket for the same reason. | Recovers by removing the single physical bucket directly. | +| `MonoStorage` over `SecureStorage` | Cannot recover a corrupted ciphertext or decoded bucket because changing one field first decrypts and decodes the bucket. | Cannot remove a logical field from a corrupted bucket for the same reason. | Recovers by removing the encrypted physical bucket without decrypting it. | + +For a corrupted `MonoStorage` bucket, use `clear()` or remove/replace its exact +physical bucket through the underlying provider or native storage API. Ordinary +logical `set()`, `update()`, and `remove()` calls intentionally fail instead of +coercing damaged data into a new bucket. ## Watching changes @@ -249,7 +461,7 @@ const unsubscribe = settings.watch((next, prev, key) => { }); ``` -Or subscribe only to specific keys: +Or watch only specific keys: ```ts const unsubscribe = settings.watch({ @@ -262,6 +474,60 @@ const unsubscribe = settings.watch({ }); ``` +`watch()` is key-oriented: when one native storage event contains several keys, +its global callback runs once for each changed logical key. Use `subscribe()` to +handle the same event as one typed changes map: + +```ts +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); + } +}); +``` + +`subscribe()` handles each matching native event after namespace filtering and +deep-equality filtering. `SecureStorage` decrypts the values first, and +`MonoStorage` expands its physical bucket change into logical key changes. The +subscriber runs once with the remaining logical changes, or is not called when +every entry is unchanged. Each entry contains the logical key's `oldValue` and +`newValue`; this also normalizes Firefox events that may include unchanged keys +passed to `set()`. + +Events are formatted in FIFO order for each registration, so the callback for an +earlier event is invoked before the callback for a later event. Returned callback +promises are observed for rejection but are not awaited; asynchronous callbacks +may overlap and cannot block later storage events. + +Calling the returned unsubscribe function immediately removes the native +listener, clears queued events, and prevents delivery after an in-progress +format/decrypt step finishes. + +Corruption or another internal formatting failure disposes that registration and +is surfaced as an uncaught asynchronous exception. There is no `onError` option, +and a `try/catch` around `watch()` or `subscribe()` cannot catch an error produced +by a later native event. User callback throws and promise rejections are also +surfaced as uncaught asynchronous exceptions, but they do not dispose the +registration; other key handlers and later events continue to run. + +For `SecureStorage`, one corrupted matching entry in a multi-key native event +rejects the whole logical event: valid sibling changes from the same provider +scope are not delivered partially, the registration is disposed, and its queued +events are discarded. This includes an unwatched sibling key in the same area +and namespace; entries from another namespace are filtered out first. The +failure is scoped to that `watch()` or `subscribe()` registration; separately +registered listeners process matching entries independently and can fail in the +same way. + +In a persistent MV2 background page, an internally failed registration remains +disposed until the page reloads. An MV3 service worker registers it again after a +later wake-up, so repeating corrupted input can produce a visible crash loop +instead of a permanently silent listener. + ## React The React adapter is available via `@addon-core/storage/react`. @@ -307,13 +573,14 @@ export function ThemeToggle() { Every storage instance exposes the same small API: -- `get(key)` -- `set(key, value)` -- `update(key, updater, options?)` +- `get(key | keys)` - `getAll()` +- `set(key, value)` or `set(values)` +- `update(key | keys, updater, options?)` - `remove(key | keys, options?)` - `clear(options?)` - `watch(callback | handlers)` +- `subscribe(callback)` ## Custom locking @@ -339,3 +606,6 @@ const storage = new Storage<{count?: number}>({ - Built for browser extensions where `chrome.storage` is available - `SecureStorage` requires Web Crypto API support - `chrome.storage` quotas still apply, especially for `sync` +- the object form of `set()` uses at most one native write operation, but total, + per-item, and item-count quotas are unchanged; a mixed batch `update()` + set-and-delete patch uses two native write operations diff --git a/package.json b/package.json index 30b97ea..60b4580 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@addon-core/storage", "version": "0.6.0", - "description": "Typed storage for browser extensions with atomic updates, namespaces, encryption, and React bindings.", + "description": "Typed storage for browser extensions with lock-coordinated updates, namespaces, encryption, and React bindings.", "type": "module", "license": "MIT", "repository": { diff --git a/src/LockManager.test.ts b/src/LockManager.test.ts index 2b3d5f7..461dd9c 100644 --- a/src/LockManager.test.ts +++ b/src/LockManager.test.ts @@ -1,78 +1,70 @@ +import {createWebLocksMock, type WebLocksMock} from "../tests/helpers/webLocks"; import LockManager from "./LockManager"; -interface TestLocks { - request(name: string, options: LockOptions, callback: LockGrantedCallback): Promise; +class TestLockManager extends LockManager { + constructor(private readonly locks: WebLocksMock) { + super("test"); + } + + protected getLocks(): Navigator["locks"] { + return this.locks as unknown as Navigator["locks"]; + } } -const createAbortError = () => { - const error = new Error("The lock request was aborted."); - error.name = "AbortError"; +describe("createWebLocksMock", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); - return error; -}; + test("supports both request signatures", async () => { + const locks = createWebLocksMock(); -const createTestLocks = (): TestLocks => { - const queues = new Map>(); + await expect(locks.request("implicit", lock => `${lock?.name}:${lock?.mode}`)).resolves.toBe( + "implicit:exclusive" + ); + await expect( + locks.request("explicit", {mode: "shared"}, lock => `${lock?.name}:${lock?.mode}`) + ).resolves.toBe("explicit:shared"); + }); - return { - async request(name: string, options: LockOptions, callback: LockGrantedCallback): Promise { - const previous = queues.get(name) ?? Promise.resolve(); - let releaseCurrent: (() => void) | undefined; + test("removes an abort listener immediately when a queued request aborts", async () => { + const locks = createWebLocksMock(); + let markFirstStarted: VoidFunction | undefined; + let releaseFirst: VoidFunction | undefined; - const current = new Promise(resolve => { - releaseCurrent = resolve; + const firstStarted = new Promise(resolve => { + markFirstStarted = resolve; + }); + const first = locks.request("settings", async () => { + markFirstStarted?.(); + await new Promise(resolve => { + releaseFirst = resolve; }); + }); - queues.set(name, previous.then(() => current)); - - const waitForTurn = new Promise((resolve, reject) => { - const onAbort = () => reject(createAbortError()); - - options.signal?.addEventListener("abort", onAbort, {once: true}); - - previous.then( - () => { - options.signal?.removeEventListener("abort", onAbort); - - if (options.signal?.aborted) { - reject(createAbortError()); - return; - } - - resolve(); - }, - reject - ); - }); + await firstStarted; - try { - await waitForTurn; - return await callback({name, mode: options.mode ?? "exclusive"} as Lock); - } finally { - releaseCurrent?.(); + const controller = new AbortController(); + const removeEventListener = jest.spyOn(controller.signal, "removeEventListener"); + const waiting = locks.request("settings", {signal: controller.signal}, () => "unreachable"); - if (queues.get(name) === current) { - queues.delete(name); - } - } - }, - }; -}; + controller.abort(); -class TestLockManager extends LockManager { - constructor(private readonly locks: TestLocks) { - super("test"); - } + await expect(waiting).rejects.toMatchObject({name: "AbortError"}); + expect(removeEventListener).toHaveBeenCalledWith("abort", expect.any(Function)); - protected getLocks(): Navigator["locks"] { - return this.locks as unknown as Navigator["locks"]; - } -} + releaseFirst?.(); + await first; + }); +}); describe("LockManager", () => { const originalLocks = globalThis.navigator.locks; afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + Object.defineProperty(globalThis.navigator, "locks", { value: originalLocks, writable: true, @@ -82,7 +74,7 @@ describe("LockManager", () => { }); test("runs tasks sequentially for the same lock name", async () => { - const lockManager = new TestLockManager(createTestLocks()); + const lockManager = new TestLockManager(createWebLocksMock()); const steps: string[] = []; await Promise.all([ @@ -111,12 +103,12 @@ describe("LockManager", () => { const lockManager = new LockManager(); await expect(lockManager.request("profile", async () => "ok")).rejects.toThrow( - "Atomic storage update is unavailable: Web Locks API is not supported in this context." + "Lock-coordinated storage update is unavailable: Web Locks API is not supported in this context." ); }); test("aborts while waiting for a queued lock", async () => { - const lockManager = new TestLockManager(createTestLocks()); + const lockManager = new TestLockManager(createWebLocksMock()); let releaseFirstLock: (() => void) | undefined; @@ -137,8 +129,60 @@ describe("LockManager", () => { await firstTask; }); + test("keeps later requests queued after an earlier waiter aborts", async () => { + const lockManager = new TestLockManager(createWebLocksMock()); + const steps: string[] = []; + + let releaseFirstLock: (() => void) | undefined; + let markFirstStarted: (() => void) | undefined; + + const firstStarted = new Promise(resolve => { + markFirstStarted = resolve; + }); + + const firstTask = lockManager.request("settings", async () => { + steps.push("first:start"); + markFirstStarted?.(); + + await new Promise(resolve => { + releaseFirstLock = resolve; + }); + + steps.push("first:end"); + }); + + await firstStarted; + + const controller = new AbortController(); + const abortedTask = lockManager.request( + "settings", + async () => { + steps.push("aborted:unexpected"); + }, + {signal: controller.signal} + ); + + controller.abort(); + await expect(abortedTask).rejects.toMatchObject({name: "AbortError"}); + + const laterTask = lockManager.request("settings", async () => { + steps.push("later:start"); + }); + + await Promise.resolve(); + await Promise.resolve(); + expect(steps).toEqual(["first:start"]); + + releaseFirstLock?.(); + await Promise.all([firstTask, laterTask]); + + expect(steps).toEqual(["first:start", "first:end", "later:start"]); + }); + test("aborts when lock wait exceeds timeout", async () => { - const lockManager = new TestLockManager(createTestLocks()); + jest.useFakeTimers(); + + const lockManager = new TestLockManager(createWebLocksMock()); let releaseFirstLock: (() => void) | undefined; @@ -150,14 +194,106 @@ describe("LockManager", () => { const waitingTask = lockManager.request("settings", async () => "unreachable", {timeout: 5}); + jest.advanceTimersByTime(5); + await expect(waitingTask).rejects.toMatchObject({name: "AbortError"}); + expect(jest.getTimerCount()).toBe(0); + + releaseFirstLock?.(); + await firstTask; + }); + + test("cleans timeout and external abort listener when the lock is granted", async () => { + jest.useFakeTimers(); + + const lockManager = new TestLockManager(createWebLocksMock()); + const controller = new AbortController(); + const removeEventListener = jest.spyOn(controller.signal, "removeEventListener"); + + let releaseTask: (() => void) | undefined; + let markTaskStarted: (() => void) | undefined; + + const taskStarted = new Promise(resolve => { + markTaskStarted = resolve; + }); + + const task = lockManager.request( + "settings", + async () => { + markTaskStarted?.(); + + await new Promise(resolve => { + releaseTask = resolve; + }); + + return "completed"; + }, + {signal: controller.signal, timeout: 1_000} + ); + + await taskStarted; + + expect(jest.getTimerCount()).toBe(0); + expect(removeEventListener).toHaveBeenCalledWith("abort", expect.any(Function)); + + controller.abort(); + jest.advanceTimersByTime(1_000); + releaseTask?.(); + + await expect(task).resolves.toBe("completed"); + }); + + test("cleans timeout and external abort listener when lock acquisition is aborted", async () => { + jest.useFakeTimers(); + + const lockManager = new TestLockManager(createWebLocksMock()); + let releaseFirstLock: (() => void) | undefined; + + const firstTask = lockManager.request("settings", async () => { + await new Promise(resolve => { + releaseFirstLock = resolve; + }); + }); + + const controller = new AbortController(); + const removeEventListener = jest.spyOn(controller.signal, "removeEventListener"); + const waitingTask = lockManager.request("settings", async () => "unreachable", { + signal: controller.signal, + timeout: 1_000, + }); + + controller.abort(); + await expect(waitingTask).rejects.toMatchObject({name: "AbortError"}); + expect(jest.getTimerCount()).toBe(0); + expect(removeEventListener).toHaveBeenCalledWith("abort", expect.any(Function)); releaseFirstLock?.(); await firstTask; }); + test("cleans timeout and external abort listener when the lock request rejects", async () => { + jest.useFakeTimers(); + + const locks = { + request: jest.fn().mockRejectedValue(new Error("Lock backend failed")), + } as unknown as WebLocksMock; + const lockManager = new TestLockManager(locks); + const controller = new AbortController(); + const removeEventListener = jest.spyOn(controller.signal, "removeEventListener"); + + await expect( + lockManager.request("settings", async () => "unreachable", { + signal: controller.signal, + timeout: 1_000, + }) + ).rejects.toThrow("Lock backend failed"); + + expect(jest.getTimerCount()).toBe(0); + expect(removeEventListener).toHaveBeenCalledWith("abort", expect.any(Function)); + }); + test("releases the lock after a task failure", async () => { - const lockManager = new TestLockManager(createTestLocks()); + const lockManager = new TestLockManager(createWebLocksMock()); await expect( lockManager.request("settings", async () => { diff --git a/src/LockManager.ts b/src/LockManager.ts index ceccade..07d58fd 100644 --- a/src/LockManager.ts +++ b/src/LockManager.ts @@ -1,13 +1,25 @@ import type {StorageLocker, StorageLockOptions} from "./types"; +interface LockRequestSignal { + signal: AbortSignal | undefined; + cleanup: () => void; +} + export default class LockManager implements StorageLocker { constructor(protected readonly prefix: string = "storage") {} public async request(name: string, task: () => Promise, options: StorageLockOptions = {}): Promise { const locks = this.getLocks(); - const signal = this.createSignal(options); + const {signal, cleanup} = this.createSignal(options); - return await locks.request(this.getLockName(name), {mode: "exclusive", signal}, async () => await task()); + try { + return await locks.request(this.getLockName(name), {mode: "exclusive", signal}, async () => { + cleanup(); + return await task(); + }); + } finally { + cleanup(); + } } protected getLockName(name: string): string { @@ -18,41 +30,54 @@ export default class LockManager implements StorageLocker { const locks = globalThis.navigator?.locks; if (!locks?.request) { - throw new Error("Atomic storage update is unavailable: Web Locks API is not supported in this context."); + throw new Error( + "Lock-coordinated storage update is unavailable: Web Locks API is not supported in this context." + ); } return locks; } - protected createSignal({signal, timeout}: StorageLockOptions): AbortSignal | undefined { + protected createSignal({signal, timeout}: StorageLockOptions): LockRequestSignal { if (timeout === undefined) { - return signal; + return {signal, cleanup: () => undefined}; } const controller = new AbortController(); - const timeoutId = globalThis.setTimeout(() => controller.abort(), timeout); + let cleaned = false; + + const onAbort = () => { + controller.abort(signal?.reason); + cleanup(); + }; + + const timeoutId = globalThis.setTimeout(() => { + controller.abort(); + cleanup(); + }, timeout); - const cleanup = () => globalThis.clearTimeout(timeoutId); + const cleanup = () => { + if (cleaned) { + return; + } - controller.signal.addEventListener("abort", cleanup, {once: true}); + cleaned = true; + globalThis.clearTimeout(timeoutId); + signal?.removeEventListener("abort", onAbort); + }; if (!signal) { - return controller.signal; + return {signal: controller.signal, cleanup}; } if (signal.aborted) { controller.abort(signal.reason); - return controller.signal; + cleanup(); + return {signal: controller.signal, cleanup}; } - signal.addEventListener( - "abort", - () => { - controller.abort(signal.reason); - }, - {once: true} - ); + signal.addEventListener("abort", onAbort, {once: true}); - return controller.signal; + return {signal: controller.signal, cleanup}; } } diff --git a/src/adapters/react/index.ts b/src/adapters/react/index.ts index 8b10ef5..ad9129e 100644 --- a/src/adapters/react/index.ts +++ b/src/adapters/react/index.ts @@ -1 +1,6 @@ -export {default as useStorage, type UseStorageOptions, type UseStorageReturnValue} from "./useStorage"; +export { + default as useStorage, + type UseStorageOptions, + type UseStorageProvider, + type UseStorageReturnValue, +} from "./useStorage"; diff --git a/src/adapters/react/useStorage.test.ts b/src/adapters/react/useStorage.test.ts index 121ab06..e925506 100644 --- a/src/adapters/react/useStorage.test.ts +++ b/src/adapters/react/useStorage.test.ts @@ -56,6 +56,26 @@ describe("behavior of default value", () => { }); }); +test("creates the default Storage.Local provider only once across rerenders", async () => { + const localSpy = jest.spyOn(Storage, "Local"); + + try { + const {rerender, result} = renderHook( + ({defaultValue}: {defaultValue: string}) => useStorage("theme", defaultValue), + {initialProps: {defaultValue: "light"}} + ); + + await waitFor(() => expect(result.current[0]).toBe("light")); + + rerender({defaultValue: "dark"}); + + await waitFor(() => expect(result.current[0]).toBe("dark")); + expect(localSpy).toHaveBeenCalledTimes(1); + } finally { + localSpy.mockRestore(); + } +}); + test("works correctly with SecureStorage instances", async () => { const storage = new SecureStorage({namespace: "user"}); const {result} = renderHook(() => useStorage({key: "theme", storage})); diff --git a/src/adapters/react/useStorage.ts b/src/adapters/react/useStorage.ts index d2a2e86..5b3d120 100644 --- a/src/adapters/react/useStorage.ts +++ b/src/adapters/react/useStorage.ts @@ -2,9 +2,11 @@ import {useCallback, useEffect, useMemo, useRef, useState} from "react"; import Storage from "../../providers/Storage"; import type {StorageProvider, StorageWatchOptions} from "../../types"; +export type UseStorageProvider = Pick>, "get" | "set" | "remove" | "watch">; + export interface UseStorageOptions { key: string; - storage?: StorageProvider>; + storage?: UseStorageProvider; defaultValue?: T; } @@ -23,49 +25,54 @@ function isOptions(arg: any): arg is UseStorageOptions { function useStorage(options: UseStorageOptions): UseStorageReturnValue; function useStorage(key: string, defaultValue?: T): UseStorageReturnValue; function useStorage(arg1: string | UseStorageOptions, arg2?: T): UseStorageReturnValue { - const key = isOptions(arg1) ? arg1?.key : arg1; - const storageRef = useRef( - isOptions(arg1) ? (arg1?.storage ?? Storage.Local>()) : Storage.Local>() - ); - const defaultValue = useMemo(() => (isOptions(arg1) ? arg1?.defaultValue : arg2), [arg1, arg2]); + const options = isOptions(arg1) ? arg1 : undefined; + const key = options?.key ?? (arg1 as string); + const storageRef = useRef(null); + + if (storageRef.current === null) { + storageRef.current = options?.storage ?? Storage.Local>(); + } + + const storage = storageRef.current; + const defaultValue = useMemo(() => (options ? options.defaultValue : arg2), [options, arg2]); const [value, setValue] = useState(undefined); const fetchValue = useCallback((): void => { - storageRef.current + storage .get(key) .then(storedValue => setValue(storedValue ?? defaultValue)) .catch(e => console.error("useStorage get storage value error", e)); - }, [key, defaultValue]); + }, [key, defaultValue, storage]); useEffect(() => { fetchValue(); - const unsubscribe = storageRef.current.watch({ + const unsubscribe = storage.watch({ [key]: (newValue: T | undefined) => setValue(newValue), } as unknown as StorageWatchOptions>); return () => unsubscribe(); - }, [key, fetchValue]); + }, [key, fetchValue, storage]); const updateValue = useCallback( (newValue: T) => { const prevValue = value; setValue(newValue); - storageRef.current.set(key, newValue).catch(e => { + storage.set(key, newValue).catch(e => { setValue(prevValue); console.error("Storage useStorage error - set storage value error", e); }); }, - [key, value] + [key, value, storage] ); const removeValue = useCallback(() => { - storageRef.current + storage .remove(key) .then(() => setValue(undefined)) .catch(e => console.error("useStorage remove storage value error", e)); - }, [key]); + }, [key, storage]); return [value, updateValue, removeValue] as const; } diff --git a/src/batch.test.ts b/src/batch.test.ts new file mode 100644 index 0000000..ee876bf --- /dev/null +++ b/src/batch.test.ts @@ -0,0 +1,156 @@ +import {planBatchUpdate} from "./batch"; + +interface BatchState { + count?: number; + label?: string; + settings?: {enabled: boolean}; + missing?: string; + "__proto__"?: string; + constructor?: string; + toString?: string; + valueOf?: string; +} + +describe("planBatchUpdate", () => { + test("plans changed values while preserving omitted and deeply equal keys", () => { + const previous = { + count: 1, + label: "before", + settings: {enabled: true}, + }; + const patch = { + count: 1, + label: "after", + }; + + const plan = planBatchUpdate( + ["count", "label", "settings"], + previous, + patch + ); + + expect(plan).toEqual({ + next: { + count: 1, + label: "after", + settings: {enabled: true}, + }, + valuesToSet: {label: "after"}, + keysToRemove: [], + }); + expect(previous).toEqual({ + count: 1, + label: "before", + settings: {enabled: true}, + }); + expect(patch).toEqual({count: 1, label: "after"}); + }); + + test("removes an existing key without scheduling removal for an absent key", () => { + const previous: Partial> = {label: "stored"}; + const patch: Partial> = { + label: undefined, + missing: undefined, + }; + + const plan = planBatchUpdate( + ["label", "missing"], + previous, + patch + ); + + expect(plan).toEqual({ + next: {}, + valuesToSet: {}, + keysToRemove: ["label"], + }); + expect(previous).toEqual({label: "stored"}); + expect(Object.keys(patch)).toEqual(["label", "missing"]); + }); + + test("uses own per-key comparers to skip or force writes", () => { + const countComparer = jest.fn(() => true); + const labelComparer = jest.fn(() => false); + + const plan = planBatchUpdate( + ["count", "label"], + {count: 1, label: "same"}, + {count: 2, label: "same"}, + {count: countComparer, label: labelComparer} + ); + + expect(countComparer).toHaveBeenCalledWith(1, 2); + expect(labelComparer).toHaveBeenCalledWith("same", "same"); + expect(plan).toEqual({ + next: {count: 1, label: "same"}, + valuesToSet: {label: "same"}, + keysToRemove: [], + }); + }); + + test("ignores inherited comparer functions", () => { + const inheritedComparer = jest.fn(() => true); + const compare = Object.create({count: inheritedComparer}); + + const plan = planBatchUpdate(["count"], {count: 1}, {count: 2}, compare); + + expect(inheritedComparer).not.toHaveBeenCalled(); + expect(plan).toEqual({ + next: {count: 2}, + valuesToSet: {count: 2}, + keysToRemove: [], + }); + }); + + test.each([null, [], "patch", 42, new Date()])("rejects a non-plain-object patch %#", patch => { + expect(() => planBatchUpdate(["count"], {count: 1}, patch as never)).toThrow( + new TypeError("Storage batch updater must return an object patch.") + ); + }); + + test("rejects an own patch key outside the selected keys", () => { + expect(() => + planBatchUpdate(["count"], {count: 1}, {label: "unexpected"} as never) + ).toThrow('Storage batch updater returned an unrequested key: "label".'); + }); + + test("preserves prototype-like keys as own data properties without changing object prototypes", () => { + type PrototypeKey = "__proto__" | "constructor" | "toString" | "valueOf"; + const keys: readonly PrototypeKey[] = ["__proto__", "constructor", "toString", "valueOf"]; + const previous = Object.create(null) as Partial>; + const patch = Object.create(null) as Partial>; + + for (const key of keys) { + Object.defineProperty(previous, key, { + configurable: true, + enumerable: true, + value: `old-${key}`, + writable: true, + }); + Object.defineProperty(patch, key, { + configurable: true, + enumerable: true, + value: `new-${key}`, + writable: true, + }); + } + + const plan = planBatchUpdate(keys, previous, patch, {}); + + expect(Object.getPrototypeOf(plan.next)).toBe(Object.prototype); + expect(Object.getPrototypeOf(plan.valuesToSet)).toBe(Object.prototype); + + for (const key of keys) { + expect(Object.getOwnPropertyDescriptor(plan.next, key)?.value).toBe(`new-${key}`); + expect(Object.getOwnPropertyDescriptor(plan.valuesToSet, key)?.value).toBe(`new-${key}`); + } + }); + + test("returns an empty plan for an empty key set", () => { + expect(planBatchUpdate([], {}, {})).toEqual({ + next: {}, + valuesToSet: {}, + keysToRemove: [], + }); + }); +}); diff --git a/src/batch.ts b/src/batch.ts new file mode 100644 index 0000000..bdfd341 --- /dev/null +++ b/src/batch.ts @@ -0,0 +1,62 @@ +import {dequal as defaultCompare} from "dequal/lite"; +import {copyRecord, createRecord, hasOwn, isPlainObject, setRecordValue} from "./utils"; +import type {StorageBatchUpdateOptions, StorageState} from "./types"; + +export interface StorageBatchPlan { + next: Partial>; + valuesToSet: Partial>; + keysToRemove: K[]; +} + +export const planBatchUpdate = ( + uniqueKeys: readonly K[], + previous: Partial>, + patch: Partial>, + compare?: StorageBatchUpdateOptions["compare"] +): StorageBatchPlan => { + if (!isPlainObject(patch)) { + throw new TypeError("Storage batch updater must return an object patch."); + } + + const preparedPatch = copyRecord(patch); + const allowedKeys = new Set(uniqueKeys.map(key => key.toString())); + const invalidKey = Object.keys(preparedPatch).find(key => !allowedKeys.has(key)); + + if (invalidKey !== undefined) { + throw new Error(`Storage batch updater returned an unrequested key: "${invalidKey}".`); + } + + const next = copyRecord(previous); + const valuesToSet = createRecord>>(); + const keysToRemove: K[] = []; + + for (const key of uniqueKeys) { + if (!hasOwn(preparedPatch, key)) { + continue; + } + + const previousValue = hasOwn(previous, key) ? previous[key] : undefined; + const nextValue = preparedPatch[key]; + + if (nextValue === undefined) { + delete next[key]; + + if (hasOwn(previous, key)) { + keysToRemove.push(key); + } + + continue; + } + + const compareValue = compare && hasOwn(compare, key) ? compare[key] : undefined; + + if ((compareValue ?? defaultCompare)(previousValue, nextValue)) { + continue; + } + + setRecordValue(valuesToSet, key, nextValue); + setRecordValue(next, key, nextValue); + } + + return {next, valuesToSet, keysToRemove}; +}; diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..1bb50f5 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1 @@ +export const STORAGE_KEY_SEPARATOR = ":" as const; diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..92af6ef --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,31 @@ +import type {StorageState} from "./types"; + +export class StorageCorruptionError extends Error { + public readonly provider: string; + public readonly key: string; + public readonly cause: unknown; + + constructor(provider: string, key: PropertyKey, cause?: unknown) { + super(`${provider} contains a corrupted value for key "${String(key)}".`); + + this.name = "StorageCorruptionError"; + this.provider = provider; + this.key = String(key); + this.cause = cause; + } +} + +export class StoragePartialUpdateError extends Error { + public readonly appliedSetKeys: readonly (keyof T)[]; + public readonly attemptedRemoveKeys: readonly (keyof T)[]; + public readonly cause: unknown; + + constructor(appliedSetKeys: readonly (keyof T)[], attemptedRemoveKeys: readonly (keyof T)[], cause: unknown) { + super("Storage batch update was only partially applied: set completed, but remove failed."); + + this.name = "StoragePartialUpdateError"; + this.appliedSetKeys = [...appliedSetKeys]; + this.attemptedRemoveKeys = [...attemptedRemoveKeys]; + this.cause = cause; + } +} diff --git a/src/index.ts b/src/index.ts index 1980552..66824ca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,20 @@ +export {StorageCorruptionError, StoragePartialUpdateError} from "./errors"; export {default as LockManager} from "./LockManager"; export {MonoStorage, SecureStorage, Storage} from "./providers"; +export type {SecureStorageOptions, StorageOptions} from "./providers"; export type { + StorageBatchUpdateOptions, + StorageBatchUpdater, + StorageChanges, + StorageListenerResult, StorageLocker, StorageLockOptions, StorageProvider, + StorageSetValue, StorageState, + StorageSubscriber, + StorageUpdateComparer, + StorageUpdateOptions, StorageUpdater, StorageWatchCallback, StorageWatchKeyCallback, diff --git a/src/providers/AbstractStorage.ts b/src/providers/AbstractStorage.ts index b529588..f6ae002 100644 --- a/src/providers/AbstractStorage.ts +++ b/src/providers/AbstractStorage.ts @@ -1,13 +1,34 @@ import {browser} from "@addon-core/browser"; import {callWithPromise, handleListener} from "@addon-core/browser/utils"; import {dequal as defaultCompare} from "dequal/lite"; +import {planBatchUpdate} from "../batch"; +import {StoragePartialUpdateError} from "../errors"; import LockManager from "../LockManager"; +import { + assertStorageKey, + assertStorageNamespace, + assertStorageSetValue, + copyRecordWithoutPrototype, + createRecord, + hasOwn, + invokeCallback, + normalizeStorageNamespace, + prepareStorageSetValues, + scheduleUnhandledError, + setRecordValue, +} from "../utils"; +import {watchChanges} from "../watch"; import MonoStorage from "./MonoStorage"; import type { + StorageBatchUpdateOptions, + StorageBatchUpdater, + StorageChanges, StorageLocker, StorageLockOptions, StorageProvider, + StorageSetValue, StorageState, + StorageSubscriber, StorageUpdateOptions, StorageUpdater, StorageWatchOptions, @@ -34,11 +55,15 @@ type OmitUndef = undefined extends T ? Omit, K> | undefined : Omit; -type FactoryOptions = WithKey>; +export type FactoryOptions = WithKey>; -type AreaOptions = OmitUndef, "area">; +export type AreaOptions = OmitUndef, "area">; -type StaticMake = StorageProvider>( +export type StaticMake = < + T extends new ( + options?: O + ) => StorageProvider, +>( this: T, options?: FactoryOptions ) => StorageProvider; @@ -48,19 +73,12 @@ export default abstract class AbstractStorage implements private readonly area: AreaName; protected readonly locker: StorageLocker; protected readonly namespace?: string; - protected separator: string = ":"; public abstract clear(options?: StorageLockOptions): Promise; protected abstract getFullKey(key: keyof T): string; - protected abstract getNamespaceOfKey(key: string): string | undefined; - - protected abstract handleChange

( - key: string, - changes: StorageChange, - options: StorageWatchOptions

- ): Promise; + protected abstract decodeFullKey(fullKey: string): keyof T | null; public static make< S extends StorageState, @@ -73,6 +91,10 @@ export default abstract class AbstractStorage implements >(this: T, options?: FactoryOptions): StorageProvider { const {key, ...rest} = options || {}; + if (typeof key === "string" && key.trim() !== "") { + assertStorageKey(key); + } + const storage = new this(rest as O); if (typeof key === "string" && key.trim() !== "") { @@ -99,9 +121,11 @@ export default abstract class AbstractStorage implements public static Session< S extends StorageState, - O extends StorageOptions, + O extends StorageOptions = StorageOptions, T extends new ( options?: O + ) => StorageProvider = new ( + options?: O ) => StorageProvider, >(this: T & {make: StaticMake}, options?: AreaOptions): StorageProvider { return this.make({ @@ -141,17 +165,65 @@ export default abstract class AbstractStorage implements } protected constructor({area, locker, namespace}: StorageOptions = {}) { + const normalizedNamespace = normalizeStorageNamespace(namespace); + assertStorageNamespace(normalizedNamespace); + this.area = area ?? "local"; this.storage = storage()[this.area]; this.locker = locker ?? new LockManager(`storage:${this.area}`); - this.namespace = namespace?.trim() ? namespace?.trim() : undefined; + this.namespace = normalizedNamespace; } - public async set(key: K, value: T[K]): Promise { - await this.setUnlocked(key, value); + public set(key: K, value: StorageSetValue): Promise; + public set(values: Partial): Promise; + public async set(keyOrValues: K | Partial, value?: T[K]): Promise { + if (arguments.length === 1) { + const values = prepareStorageSetValues>(keyOrValues); + + for (const key of Object.keys(values) as (keyof T)[]) { + assertStorageKey(key); + } + + await this.setBatchUnlocked(values); + return; + } + + assertStorageKey(keyOrValues as K); + assertStorageSetValue(value); + await this.setUnlocked(keyOrValues as K, value as T[K]); } + public update( + key: K, + updater: StorageUpdater, + options?: StorageUpdateOptions + ): Promise; + public update( + keys: readonly K[], + updater: StorageBatchUpdater, + options?: StorageBatchUpdateOptions + ): Promise>>; public async update( + keyOrKeys: K | readonly K[], + updater: StorageUpdater | StorageBatchUpdater, + options?: StorageUpdateOptions | StorageBatchUpdateOptions + ): Promise>> { + if (Array.isArray(keyOrKeys)) { + return await this.updateBatch( + keyOrKeys as readonly K[], + updater as StorageBatchUpdater, + options as StorageBatchUpdateOptions | undefined + ); + } + + return await this.updateSingle( + keyOrKeys as K, + updater as StorageUpdater, + options as StorageUpdateOptions | undefined + ); + } + + private async updateSingle( key: K, updater: StorageUpdater, options?: StorageUpdateOptions @@ -185,39 +257,100 @@ export default abstract class AbstractStorage implements ); } - public async get(key: K): Promise { - return await this.getUnlocked(key); + private async updateBatch( + keys: readonly K[], + updater: StorageBatchUpdater, + options?: StorageBatchUpdateOptions + ): Promise>> { + const uniqueKeys = this.getUniqueKeys(keys); + + if (uniqueKeys.length === 0) { + return {}; + } + + const {compare, ...lockOptions} = options ?? {}; + + return await this.requestLocks( + uniqueKeys, + async () => { + const previous = await this.getBatchUnlocked(uniqueKeys); + const patch = await updater(copyRecordWithoutPrototype(previous)); + const {next, valuesToSet, keysToRemove} = planBatchUpdate(uniqueKeys, previous, patch, compare); + const setKeys = uniqueKeys.filter(key => hasOwn(valuesToSet, key)); + + if (setKeys.length > 0) { + await this.setBatchUnlocked(valuesToSet as Partial); + } + + if (keysToRemove.length > 0) { + try { + await this.removeUnlocked(keysToRemove); + } catch (error) { + if (setKeys.length > 0) { + throw new StoragePartialUpdateError(setKeys, keysToRemove, error); + } + + throw error; + } + } + + return next; + }, + lockOptions + ); + } + + public get(key: K): Promise; + public get(keys: readonly K[]): Promise>>; + public async get(keyOrKeys: K | readonly K[]): Promise>> { + if (Array.isArray(keyOrKeys)) { + return await this.getBatchUnlocked(keyOrKeys as readonly K[]); + } + + return await this.getUnlocked(keyOrKeys as K); } public async getAll(): Promise> { + return await this.getAllStoredValues(); + } + + protected async getAllStoredValues(): Promise> { + const result = await this.getStoredItems(null); + const formattedResult = createRecord>>(); + + for (const [key, value] of Object.entries(result)) { + const logicalKey = this.decodeFullKey(key); + + if (logicalKey !== null) { + setRecordValue(formattedResult, logicalKey, value as T[keyof T]); + } + } + + return formattedResult as Partial; + } + + protected async getStoredItems(keys: string | string[] | null): Promise> { return await callWithPromise(resolve => { - this.storage.get(null, result => { - const formattedResult: Partial> = {}; + this.storage.get(keys, result => { + const items = createRecord>(); for (const [key, value] of Object.entries(result)) { - if (this.isKeyValid(key)) { - const original = this.getOriginalKey(key) as keyof T; - formattedResult[original] = value as T[keyof T]; - } + setRecordValue(items, key, value); } - resolve(formattedResult as Partial); + resolve(items); }); }); } public async remove(keys: K | K[], options?: StorageLockOptions): Promise { - const list = Array.isArray(keys) ? keys : [keys]; + const list = this.getUniqueKeys(Array.isArray(keys) ? keys : [keys]); - for (const key of list) { - await this.locker.request( - this.getLockKey(key), - async () => { - await this.removeUnlocked(key); - }, - options - ); + if (list.length === 0) { + return; } + + await this.requestLocks(list, async () => await this.removeUnlocked(list), options); } protected async setUnlocked(key: K, value: T[K]): Promise { @@ -228,16 +361,50 @@ export default abstract class AbstractStorage implements }); } - protected async getUnlocked(key: K): Promise { - const fullKey = this.getFullKey(key); + protected async setBatchUnlocked(values: Partial): Promise { + const items: Record = {}; + + for (const key of Object.keys(values) as (keyof T)[]) { + setRecordValue(items, this.getFullKey(key), values[key]); + } - return await callWithPromise(resolve => { - this.storage.get(fullKey, result => { - resolve(result[fullKey] as T[K] | undefined); + if (Object.keys(items).length === 0) { + return; + } + + return await callWithPromise(resolve => { + this.storage.set(items, () => { + resolve(); }); }); } + protected async getUnlocked(key: K): Promise { + const fullKey = this.getFullKey(key); + const result = await this.getStoredItems(fullKey); + + return hasOwn(result, fullKey) ? (result[fullKey] as T[K] | undefined) : undefined; + } + + protected async getBatchUnlocked(keys: readonly K[]): Promise>> { + const keyEntries = this.getUniqueKeys(keys).map(key => ({key, fullKey: this.getFullKey(key)})); + + if (keyEntries.length === 0) { + return {}; + } + + const result = await this.getStoredItems(keyEntries.map(({fullKey}) => fullKey)); + const values = createRecord>>(); + + for (const {key, fullKey} of keyEntries) { + if (hasOwn(result, fullKey)) { + setRecordValue(values, key, result[fullKey] as T[K] | undefined); + } + } + + return values; + } + protected async removeUnlocked(keys: K | K[]): Promise { return await callWithPromise(resolve => { const fullKeys = Array.isArray(keys) ? keys.map(key => this.getFullKey(key)) : this.getFullKey(keys); @@ -248,66 +415,168 @@ export default abstract class AbstractStorage implements }); } - public watch

(options: StorageWatchOptions

): () => void { - const listener: onChangedListener = (changes: Record, area: AreaName) => { - if (area !== this.area) { + public watch(watcher: StorageWatchOptions): () => void { + if (typeof watcher !== "function") { + for (const key of Object.keys(watcher) as (keyof T)[]) { + assertStorageKey(key); + } + } + + return watchChanges(callback => this.subscribe(callback), watcher); + } + + public subscribe(callback: StorageSubscriber): () => void { + const queue: [keyof T, StorageChange][][] = []; + let disposed = false; + let processing = false; + let removeNativeListener: () => void = () => undefined; + + const dispose = (): void => { + if (disposed) { + return; + } + + disposed = true; + queue.length = 0; + removeNativeListener(); + }; + + const processQueue = async (): Promise => { + if (processing || disposed) { return; } - const entries = Object.entries(changes).reduce( - (acc, [key, change]) => { - if (this.isKeyValid(key)) { - acc.push({key, task: this.handleChange(key, change, options)}); + processing = true; + + try { + while (!disposed && queue.length > 0) { + const entries = queue.shift(); + + if (!entries) { + continue; } - return acc; - }, - [] as {key: string; task: Promise}[] - ); + const formattedEntries: Awaited>>[] = []; - Promise.allSettled(entries.map(e => e.task)).then(results => { - results.forEach((result, i) => { - if (result.status === "rejected") { - const key = entries[i]?.key ?? "(unknown)"; - const namespace = this.namespace ? ` with namespace "${this.namespace}"` : ""; + for (const [key, change] of entries) { + const formattedChange = await this.formatChange(key, change); - console.error( - `Storage watch error: failed to handle change for key "${key}" in area "${this.area}"${namespace}:`, - result.reason - ); + if (disposed) { + return; + } + + formattedEntries.push(formattedChange); } - }); - }); + + const formattedChanges = createRecord>(); + + for (const {key, newValue, oldValue} of formattedEntries) { + if (defaultCompare(newValue, oldValue)) { + continue; + } + + setRecordValue(formattedChanges, key, {newValue, oldValue}); + } + + if (!disposed && Object.keys(formattedChanges).length > 0) { + invokeCallback(() => callback(formattedChanges)); + } + } + } catch (error) { + if (!disposed) { + dispose(); + scheduleUnhandledError(error); + } + } finally { + processing = false; + } }; - return handleListener(storage().onChanged, listener); - } + const listener: onChangedListener = (changes: Record, area: AreaName) => { + if (disposed || area !== this.area) { + return; + } - protected isKeyValid(key: string): boolean { - return this.getNamespaceOfKey(key) === this.namespace; - } + const entries: [keyof T, StorageChange][] = []; - protected async triggerChange

(key: string, changes: StorageChange, options: StorageWatchOptions

) { - const {newValue, oldValue} = changes; + for (const [fullKey, change] of Object.entries(changes)) { + const logicalKey = this.decodeFullKey(fullKey); - const originalKey = this.getOriginalKey(key) as keyof P; - const nextValue = newValue as P[keyof P] | undefined; - const prevValue = oldValue as P[keyof P] | undefined; + if (logicalKey !== null) { + entries.push([logicalKey, change]); + } + } - if (typeof options === "function") { - options(nextValue, prevValue, originalKey); - } else if (options[originalKey]) { - options[originalKey]?.(nextValue, prevValue); - } + if (entries.length === 0) { + return; + } + + queue.push(entries); + void processQueue(); + }; + + removeNativeListener = handleListener(storage().onChanged, listener); + + return dispose; } - protected getOriginalKey(key: string): keyof T { - const fullKeyParts = key.split(this.separator); + protected async formatChange

( + key: keyof P, + changes: StorageChange + ): Promise<{ + key: keyof P; + newValue: P[keyof P] | undefined; + oldValue: P[keyof P] | undefined; + }> { + return { + key, + newValue: changes.newValue as P[keyof P] | undefined, + oldValue: changes.oldValue as P[keyof P] | undefined, + }; + } - return fullKeyParts.length > 1 ? fullKeyParts[fullKeyParts.length - 1] : key; + protected toLogicalKey(key: keyof T): string { + const logicalKey = key.toString(); + assertStorageKey(logicalKey); + + return logicalKey; } protected getLockKey(key: K): string { return this.getFullKey(key); } + + private getUniqueKeys(keys: readonly K[]): K[] { + const uniqueKeys = new Map(); + + for (const key of keys) { + const fullKey = this.getFullKey(key); + + if (!uniqueKeys.has(fullKey)) { + uniqueKeys.set(fullKey, key); + } + } + + return [...uniqueKeys.values()]; + } + + private async requestLocks( + keys: readonly K[], + task: () => Promise, + options?: StorageLockOptions + ): Promise { + const lockNames = [...new Set(keys.map(key => this.getLockKey(key)))].sort(); + + const requestNext = async (index: number): Promise => { + const lockName = lockNames[index]; + + if (lockName === undefined) { + return await task(); + } + + return await this.locker.request(lockName, async () => await requestNext(index + 1), options); + }; + + return await requestNext(0); + } } diff --git a/src/providers/MonoStorage.test.ts b/src/providers/MonoStorage.test.ts index 4f033b7..59b806c 100644 --- a/src/providers/MonoStorage.test.ts +++ b/src/providers/MonoStorage.test.ts @@ -1,6 +1,8 @@ import MonoStorage from "./MonoStorage"; import SecureStorage from "./SecureStorage"; import Storage from "./Storage"; +import {StorageCorruptionError} from "../errors"; +import {captureUnhandledErrors, flushMacrotask} from "../../tests/helpers/async"; interface BucketState { a?: number; @@ -14,6 +16,7 @@ let base: Storage>>; let secureBase: SecureStorage>>; beforeEach(async () => { + global.resetStorageChangeListeners(); base = new Storage(); secureBase = new SecureStorage(); await chrome.storage.local.clear(); @@ -62,6 +65,256 @@ test("set/get basic behavior", async () => { expect(raw).toEqual({a: 1, c: "hello"}); }); +test("logical keys containing the namespace separator remain inside the bucket", async () => { + interface ColonKeyState { + "feature:enabled"?: number; + } + + const logicalKey = "feature:enabled" as const; + const underlying = new Storage>>(); + const mono = new MonoStorage(key, underlying); + + await mono.set(logicalKey, 1); + await expect(mono.get(logicalKey)).resolves.toBe(1); + + await mono.update(logicalKey, previous => (previous ?? 0) + 1); + await expect(mono.get(logicalKey)).resolves.toBe(2); + await expect(global.storageLocalGet(key, underlying)).resolves.toEqual({[logicalKey]: 2}); + + await mono.remove(logicalKey); + await expect(mono.get(logicalKey)).resolves.toBeUndefined(); + await expect(global.storageLocalGet(key, underlying)).resolves.toBeUndefined(); +}); + +test("set performs a physical bucket write even when the value is deeply equal", async () => { + const mono = new MonoStorage(key, base); + await mono.set("b", {x: 1}); + + const setSpy = chrome.storage.local.set as jest.Mock; + setSpy.mockClear(); + + await mono.set("b", {x: 1}); + + expect(setSpy).toHaveBeenCalledTimes(1); + expect(setSpy).toHaveBeenCalledWith({bucket: {b: {x: 1}}}, expect.any(Function)); +}); + +test("set rejects undefined and non-plain batch containers before touching the bucket", async () => { + const mono = new MonoStorage(key, base); + const getSpy = chrome.storage.local.get as jest.Mock; + const setSpy = chrome.storage.local.set as jest.Mock; + getSpy.mockClear(); + setSpy.mockClear(); + + await expect(mono.set({a: undefined})).rejects.toThrow(TypeError); + await expect((mono.set as (value: unknown) => Promise)("a")).rejects.toThrow(TypeError); + + expect(getSpy).not.toHaveBeenCalled(); + expect(setSpy).not.toHaveBeenCalled(); +}); + +test("batch set snapshots getter values once before locking the bucket", async () => { + const mono = new MonoStorage(key, base); + const values: Partial = {}; + let reads = 0; + + Object.defineProperty(values, "a", { + enumerable: true, + get: () => { + reads += 1; + return reads === 1 ? 1 : undefined; + }, + }); + + await mono.set(values); + + expect(reads).toBe(1); + await expect(mono.get("a")).resolves.toBe(1); +}); + +test("a prototype-like physical bucket key is absent until it is explicitly stored", async () => { + const prototypeKey = "toString" as const; + const underlying = new Storage>>(); + const mono = new MonoStorage(prototypeKey, underlying); + + await expect(mono.get("a")).resolves.toBeUndefined(); + await mono.set("a", 1); + await expect(mono.get("a")).resolves.toBe(1); +}); + +describe("batch overloads", () => { + test("batch set writes one namespaced physical bucket and batch get reads it once", async () => { + const underlying = new Storage>>({namespace: "feature"}); + const mono = new MonoStorage(key, underlying); + const setSpy = chrome.storage.local.set as jest.Mock; + setSpy.mockClear(); + + await mono.set({a: 1, b: 2, c: "ready"}); + + expect(setSpy).toHaveBeenCalledTimes(1); + expect(setSpy).toHaveBeenCalledWith( + {"feature:bucket": {a: 1, b: 2, c: "ready"}}, + expect.any(Function) + ); + + const getSpy = chrome.storage.local.get as jest.Mock; + getSpy.mockClear(); + + await expect(mono.get(["a", "c"] as const)).resolves.toEqual({a: 1, c: "ready"}); + expect(getSpy).toHaveBeenCalledTimes(1); + expect(getSpy).toHaveBeenCalledWith("feature:bucket", expect.any(Function)); + }); + + test("batch update applies changed, equal, and deleted inner keys in one physical write", async () => { + const underlying = new Storage>>(); + const mono = new MonoStorage(key, underlying); + await mono.set({a: 1, b: 2, c: "old"}); + + const setSpy = chrome.storage.local.set as jest.Mock; + const removeSpy = chrome.storage.local.remove as jest.Mock; + setSpy.mockClear(); + removeSpy.mockClear(); + + const result = await mono.update(["a", "b", "c"] as const, () => ({ + a: undefined, + b: 2, + c: "new", + })); + + expect(result).toEqual({b: 2, c: "new"}); + expect(setSpy).toHaveBeenCalledTimes(1); + expect(setSpy).toHaveBeenCalledWith({bucket: {b: 2, c: "new"}}, expect.any(Function)); + expect(removeSpy).not.toHaveBeenCalled(); + expect(await mono.getAll()).toEqual({b: 2, c: "new"}); + }); + + test("batch update removes the physical bucket once when the final state is empty", async () => { + const underlying = new Storage>>(); + const mono = new MonoStorage(key, underlying); + await mono.set({a: 1, c: "old"}); + + const setSpy = chrome.storage.local.set as jest.Mock; + const removeSpy = chrome.storage.local.remove as jest.Mock; + setSpy.mockClear(); + removeSpy.mockClear(); + + const result = await mono.update(["a", "c"] as const, () => ({a: undefined, c: undefined})); + + expect(result).toEqual({}); + expect(setSpy).not.toHaveBeenCalled(); + expect(removeSpy).toHaveBeenCalledTimes(1); + expect(removeSpy).toHaveBeenCalledWith("bucket", expect.any(Function)); + }); + + test("batch update applies per-key comparers before deciding whether to write the bucket", async () => { + const underlying = new Storage>>(); + const mono = new MonoStorage(key, underlying); + await mono.set({a: 1, b: 2}); + + const compareA = jest.fn(() => true); + const compareB = jest.fn(() => false); + const setSpy = chrome.storage.local.set as jest.Mock; + setSpy.mockClear(); + + const result = await mono.update( + ["a", "b"] as const, + () => ({a: 2, b: 2}), + {compare: {a: compareA, b: compareB}} + ); + + expect(compareA).toHaveBeenCalledWith(1, 2); + expect(compareB).toHaveBeenCalledWith(2, 2); + expect(result).toEqual({a: 1, b: 2}); + expect(setSpy).toHaveBeenCalledTimes(1); + expect(setSpy).toHaveBeenCalledWith({bucket: {a: 1, b: 2}}, expect.any(Function)); + }); + + test("batch update rejects unrequested keys without changing the bucket", async () => { + const underlying = new Storage>>(); + const mono = new MonoStorage(key, underlying); + await mono.set({a: 1}); + + const setSpy = chrome.storage.local.set as jest.Mock; + const removeSpy = chrome.storage.local.remove as jest.Mock; + setSpy.mockClear(); + removeSpy.mockClear(); + + await expect( + mono.update(["a"] as const, () => ({b: 2}) as unknown as Partial>) + ).rejects.toThrow("unrequested key"); + + expect(setSpy).not.toHaveBeenCalled(); + expect(removeSpy).not.toHaveBeenCalled(); + expect(await mono.getAll()).toEqual({a: 1}); + }); + + test("empty batch operations do not read, lock, or write the physical bucket", async () => { + const underlying = new Storage>>(); + const mono = new MonoStorage(key, underlying); + const updater = jest.fn(() => ({})); + const getSpy = chrome.storage.local.get as jest.Mock; + const setSpy = chrome.storage.local.set as jest.Mock; + const removeSpy = chrome.storage.local.remove as jest.Mock; + getSpy.mockClear(); + setSpy.mockClear(); + removeSpy.mockClear(); + + await expect(mono.get([])).resolves.toEqual({}); + await expect(mono.set({})).resolves.toBeUndefined(); + await expect(mono.update([], updater)).resolves.toEqual({}); + await expect(mono.remove([])).resolves.toBeUndefined(); + + expect(updater).not.toHaveBeenCalled(); + expect(getSpy).not.toHaveBeenCalled(); + expect(setSpy).not.toHaveBeenCalled(); + expect(removeSpy).not.toHaveBeenCalled(); + }); + + test("batch update serializes concurrent bucket batches", async () => { + const underlying = new Storage>>(); + const mono = new MonoStorage(key, underlying); + await mono.set({a: 0, b: 0}); + + await Promise.all([ + mono.update(["a", "b"] as const, async prev => { + await new Promise(resolve => setTimeout(resolve, 10)); + return {a: (prev.a ?? 0) + 1, b: Number(prev.b ?? 0) + 1}; + }), + mono.update(["b", "a"] as const, async prev => { + await new Promise(resolve => setTimeout(resolve, 10)); + return {a: (prev.a ?? 0) + 1, b: Number(prev.b ?? 0) + 1}; + }), + ]); + + expect(await mono.get(["a", "b"] as const)).toEqual({a: 2, b: 2}); + }); + + test("removing a missing logical key does not create an empty bucket", async () => { + const mono = new MonoStorage(key, base); + const setSpy = chrome.storage.local.set as jest.Mock; + const removeSpy = chrome.storage.local.remove as jest.Mock; + setSpy.mockClear(); + removeSpy.mockClear(); + + await mono.remove("a"); + + expect(setSpy).not.toHaveBeenCalled(); + expect(removeSpy).not.toHaveBeenCalled(); + expect(await (global as any).storageLocalGet(key, base)).toBeUndefined(); + }); + + test("single update comparer can force a deeply-equal physical write", async () => { + const mono = new MonoStorage(key, base); + await mono.set("b", {x: 1}); + const setSpy = chrome.storage.local.set as jest.Mock; + setSpy.mockClear(); + + await mono.update("b", previous => ({...(previous as {x: number})}), {compare: () => false}); + + expect(setSpy).toHaveBeenCalledTimes(1); + }); +}); + test("update serializes concurrent bucket mutations", async () => { const mono = new MonoStorage(key, base); @@ -131,17 +384,17 @@ test("getAll returns the whole bucket", async () => { expect(await mono.getAll()).toEqual({a: 1, b: {x: 2}}); }); -test("set(undefined) deletes key and removes physical entry when empty", async () => { +test("update(undefined) deletes key and removes physical entry when empty", async () => { const mono = new MonoStorage(key, base); await mono.set("a", 1); await mono.set("b", 2); - await mono.set("a", undefined as any); + await mono.update("a", () => undefined); expect(await mono.get("a")).toBeUndefined(); let raw = await (global as any).storageLocalGet(key, base); expect(raw).toEqual({b: 2}); - await mono.set("b", undefined as any); + await mono.update("b", () => undefined); // physical key should be removed entirely raw = await (global as any).storageLocalGet(key, base); expect(raw).toBeUndefined(); @@ -172,8 +425,32 @@ test("clear removes the physical key", async () => { expect(await mono.getAll()).toEqual({}); }); -describe("watch", () => { - test("global callback is called per changed inner key and provides the key", () => { +describe("watch and subscribe", () => { + test("subscribe emits one change map for one physical bucket event", async () => { + const underlying = new Storage>>(); + const mono = new MonoStorage(key, underlying); + const callback = jest.fn(); + const unsubscribe = mono.subscribe(callback); + + global.simulateStorageChange({ + storage: underlying, + key, + oldValue: {a: 1, b: {x: 1}}, + newValue: {a: 2, b: {x: 1}, c: "created"}, + }); + + await flushMacrotask(); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith({ + a: {oldValue: 1, newValue: 2}, + c: {oldValue: undefined, newValue: "created"}, + }); + + unsubscribe(); + }); + + test("global callback is called per changed inner key and provides the key", async () => { const mono = new MonoStorage(key, base); const cb = jest.fn(); mono.watch(cb); @@ -186,11 +463,13 @@ describe("watch", () => { newValue: {a: 2, c: "x"}, }); + await flushMacrotask(); + expect(cb).toHaveBeenCalledWith(2, 1, "a"); expect(cb).toHaveBeenCalledWith("x", undefined, "c"); }); - test("keyed callbacks fan-out only on changed keys", () => { + test("keyed callbacks fan-out only on changed keys", async () => { const mono = new MonoStorage(key, base); const cbA = jest.fn(); const cbB = jest.fn(); @@ -204,12 +483,14 @@ describe("watch", () => { newValue: {a: 3, b: 2, c: "x"}, }); + await flushMacrotask(); + expect(cbA).toHaveBeenCalledWith(3, 1); // changed expect(cbB).not.toHaveBeenCalled(); // unchanged expect(cbC).toHaveBeenCalledWith("x", undefined); // added }); - test("shallowEqual prevents notifications for equal objects", () => { + test("shallowEqual prevents notifications for equal objects", async () => { const mono = new MonoStorage(key, base); const cbB = jest.fn(); mono.watch({b: cbB}); @@ -221,8 +502,80 @@ describe("watch", () => { newValue: {b: {x: 1}}, // shallowly equal }); + await flushMacrotask(); + expect(cbB).not.toHaveBeenCalled(); }); + + test("subscriber rejection is uncaught without disposing the Mono subscription", async () => { + const mono = new MonoStorage(key, base); + const errors = captureUnhandledErrors(); + const callback = jest.fn().mockRejectedValueOnce(new Error("subscriber failed")).mockResolvedValue(undefined); + const unsubscribe = mono.subscribe(callback); + + try { + global.simulateStorageChange({storage: base, key, oldValue: {a: 1}, newValue: {a: 2}}); + await flushMacrotask(); + + expect(callback).toHaveBeenCalledTimes(1); + expect(errors.pending).toBe(1); + expect(() => errors.runNext()).toThrow("subscriber failed"); + + global.simulateStorageChange({storage: base, key, oldValue: {a: 2}, newValue: {a: 3}}); + await flushMacrotask(); + expect(callback).toHaveBeenCalledTimes(2); + } finally { + unsubscribe(); + errors.restore(); + } + }); + + test("a corrupted bucket terminates the Mono subscription", async () => { + const mono = new MonoStorage(key, base); + const errors = captureUnhandledErrors(); + const callback = jest.fn(); + mono.subscribe(callback); + + try { + global.simulateStorageChange({storage: base, key, oldValue: {a: 1}, newValue: []}); + await flushMacrotask(); + + expect(callback).not.toHaveBeenCalled(); + expect(errors.pending).toBe(1); + expect(() => errors.runNext()).toThrow(StorageCorruptionError); + + global.simulateStorageChange({storage: base, key, oldValue: {a: 1}, newValue: {a: 2}}); + await flushMacrotask(); + expect(callback).not.toHaveBeenCalled(); + } finally { + errors.restore(); + } + }); +}); + +describe("corrupted buckets", () => { + test.each([null, "invalid", [], 42])("rejects a present non-record bucket %#", async bucket => { + const mono = new MonoStorage(key, base); + await chrome.storage.local.set({[key]: bucket}); + + await expect(mono.getAll()).rejects.toBeInstanceOf(StorageCorruptionError); + await expect(mono.set("a", 1)).rejects.toBeInstanceOf(StorageCorruptionError); + await expect(mono.update("a", () => 1)).rejects.toBeInstanceOf(StorageCorruptionError); + await expect(mono.remove("a")).rejects.toBeInstanceOf(StorageCorruptionError); + }); + + test("clear removes a corrupted encrypted bucket without decrypting it", async () => { + const mono = new MonoStorage(key, secureBase as any); + const fullKey = (secureBase as any).getFullKey(key); + await chrome.storage.local.set({[fullKey]: "corrupted:bucket"}); + const decryptSpy = crypto.subtle.decrypt as jest.Mock; + decryptSpy.mockClear(); + + await mono.clear(); + + expect(await (global as any).storageLocalGet(key, secureBase)).toBeUndefined(); + expect(decryptSpy).not.toHaveBeenCalled(); + }); }); test("works with SecureStorage as underlying provider", async () => { @@ -236,6 +589,9 @@ test("works with SecureStorage as underlying provider", async () => { const raw = await (global as any).storageLocalGet(key, secureBase); expect(typeof raw).toBe("string"); + const allRaw = await chrome.storage.local.get(null); + expect(typeof allRaw["secure::bucket"]).toBe("string"); + expect(allRaw["secure:bucket"]).toBeUndefined(); const decryptedAll = await secureBase.getAll(); expect(decryptedAll[key]).toEqual({a: 1, c: "sec"}); diff --git a/src/providers/MonoStorage.ts b/src/providers/MonoStorage.ts index 022e493..3fe14e8 100644 --- a/src/providers/MonoStorage.ts +++ b/src/providers/MonoStorage.ts @@ -1,13 +1,41 @@ import {dequal as isEqual} from "dequal/lite"; +import {planBatchUpdate} from "../batch"; +import {StorageCorruptionError} from "../errors"; +import { + assertStorageSetValue, + copyRecord, + copyRecordWithoutPrototype, + createRecord, + hasOwn, + invokeCallback, + isPlainObject, + prepareStorageSetValues, + scheduleUnhandledError, + setRecordValue, +} from "../utils"; +import {watchChanges} from "../watch"; import type { + StorageBatchUpdateOptions, + StorageBatchUpdater, + StorageChanges, StorageLockOptions, StorageProvider, + StorageSetValue, StorageState, + StorageSubscriber, StorageUpdateOptions, StorageUpdater, StorageWatchOptions, } from "../types"; +/** + * Bucket updaters return the received `bucketValue` when nothing changed and a + * freshly built bucket otherwise, so reference identity — not deep equality — + * decides whether the underlying provider writes. This is what lets a custom + * per-field comparer force a physically identical write. + */ +const isUnchangedBucket = (previousBucket: unknown, nextBucket: unknown): boolean => previousBucket === nextBucket; + export default class MonoStorage implements StorageProvider { constructor( public readonly key: K, @@ -18,90 +46,257 @@ export default class MonoStorage imple } } + private decodeBucket(value: unknown): Partial { + if (value === undefined) { + return {}; + } + + if (!isPlainObject(value)) { + throw new StorageCorruptionError( + "MonoStorage", + this.key, + new TypeError("MonoStorage bucket must be a plain object.") + ); + } + + return value as Partial; + } + private async read(): Promise> { - const obj = await this.storage.get(this.key); + return this.decodeBucket(await this.storage.get(this.key)); + } + + public set(key: KP, value: StorageSetValue): Promise; + public set(values: Partial): Promise; + public async set( + ...args: [key: KP, value: StorageSetValue] | [values: Partial] + ): Promise { + if (args.length === 1) { + const values = prepareStorageSetValues>(args[0]); + await this.setBatch(values); + return; + } + + const [key, value] = args; + assertStorageSetValue(value); + + const values = createRecord>(); + setRecordValue(values, key, value); + await this.setBatch(values); + } + + private async setBatch(values: Partial): Promise { + const keys = Object.keys(values) as (keyof T)[]; + + if (keys.length === 0) { + return; + } + + await this.storage.update( + this.key, + bucketValue => { + const nextBucket = copyRecord(this.decodeBucket(bucketValue)); + + for (const currentKey of keys) { + setRecordValue(nextBucket, currentKey, values[currentKey]); + } - return obj && typeof obj === "object" ? obj : {}; + return nextBucket; + }, + {compare: () => false} + ); } - public async set(key: KP, value: T[KP]): Promise { - await this.update(key, () => value); + public get(key: KP): Promise; + public get(keys: readonly KP[]): Promise>>; + public async get( + keyOrKeys: KP | readonly KP[] + ): Promise>> { + if (Array.isArray(keyOrKeys)) { + return await this.getBatch(keyOrKeys as readonly KP[]); + } + + const bucket = await this.read(); + const key = keyOrKeys as KP; + + return hasOwn(bucket, key) ? (bucket[key] as T[KP]) : undefined; } - public async get(key: KP): Promise { + private async getBatch(keys: readonly KP[]): Promise>> { + const uniqueKeys = this.getUniqueKeys(keys); + + if (uniqueKeys.length === 0) { + return {}; + } + const bucket = await this.read(); + const values = createRecord>>(); + + for (const currentKey of uniqueKeys) { + if (hasOwn(bucket, currentKey)) { + setRecordValue(values, currentKey, bucket[currentKey] as T[KP]); + } + } - return bucket[key] as T[KP] | undefined; + return values; } + public update( + key: KP, + updater: StorageUpdater, + options?: StorageUpdateOptions + ): Promise; + public update( + keys: readonly KP[], + updater: StorageBatchUpdater, + options?: StorageBatchUpdateOptions + ): Promise>>; public async update( + keyOrKeys: KP | readonly KP[], + updater: StorageUpdater | StorageBatchUpdater, + options?: StorageUpdateOptions | StorageBatchUpdateOptions + ): Promise>> { + if (Array.isArray(keyOrKeys)) { + return await this.updateBatch( + keyOrKeys as readonly KP[], + updater as StorageBatchUpdater, + options as StorageBatchUpdateOptions | undefined + ); + } + + return await this.updateSingle( + keyOrKeys as KP, + updater as StorageUpdater, + options as StorageUpdateOptions | undefined + ); + } + + private async updateSingle( key: KP, updater: StorageUpdater, options?: StorageUpdateOptions ): Promise { - const {compare = isEqual, ...lockOptions} = options ?? {}; + const {compare, ...lockOptions} = options ?? {}; + const compareValue = compare ?? isEqual; + let result: T[KP] | undefined; - const nextBucket = await this.storage.update( + await this.storage.update( this.key, async bucketValue => { - const bucket: Partial = bucketValue && typeof bucketValue === "object" ? bucketValue : {}; - const prevValue = bucket[key] as T[KP] | undefined; - const nextValue = await updater(prevValue); + const bucket = this.decodeBucket(bucketValue); + const previousValue = hasOwn(bucket, key) ? (bucket[key] as T[KP]) : undefined; + const nextValue = await updater(previousValue); + + result = nextValue; if (nextValue === undefined) { - if (!(key in bucket)) { + if (!hasOwn(bucket, key)) { return bucketValue; } - const next = {...bucket}; - delete next[key]; + const nextBucket = copyRecord(bucket); + delete nextBucket[key]; + + return Object.keys(nextBucket).length === 0 ? undefined : nextBucket; + } + + if (compareValue(previousValue, nextValue)) { + return bucketValue; + } + + const nextBucket = copyRecord(bucket); + setRecordValue(nextBucket, key, nextValue); + + return nextBucket; + }, + {...lockOptions, compare: isUnchangedBucket} + ); + + return result; + } + + private async updateBatch( + keys: readonly KP[], + updater: StorageBatchUpdater, + options?: StorageBatchUpdateOptions + ): Promise>> { + const uniqueKeys = this.getUniqueKeys(keys); + + if (uniqueKeys.length === 0) { + return {}; + } + + const {compare, ...lockOptions} = options ?? {}; + let updatedValues = createRecord>>(); + + await this.storage.update( + this.key, + async bucketValue => { + const bucket = this.decodeBucket(bucketValue); + const previousValues = createRecord>>(); - return Object.keys(next).length === 0 ? undefined : next; + for (const currentKey of uniqueKeys) { + if (hasOwn(bucket, currentKey)) { + setRecordValue(previousValues, currentKey, bucket[currentKey] as T[KP]); + } } - if (compare(prevValue, nextValue)) { + const patch = await updater(copyRecordWithoutPrototype(previousValues)); + const plan = planBatchUpdate(uniqueKeys, previousValues, patch, compare); + updatedValues = plan.next; + + if (Object.keys(plan.valuesToSet).length === 0 && plan.keysToRemove.length === 0) { return bucketValue; } - return {...bucket, [key]: nextValue}; + const nextBucket = copyRecord(bucket); + + for (const currentKey of Object.keys(plan.valuesToSet) as KP[]) { + setRecordValue(nextBucket, currentKey, plan.valuesToSet[currentKey]); + } + + for (const currentKey of plan.keysToRemove) { + delete nextBucket[currentKey]; + } + + return Object.keys(nextBucket).length === 0 ? undefined : nextBucket; }, - lockOptions + {...lockOptions, compare: isUnchangedBucket} ); - return nextBucket?.[key] as T[KP] | undefined; + return updatedValues; } public async getAll(): Promise> { - return await this.read(); + return copyRecord(await this.read()); } public async remove(keys: KP | KP[], options?: StorageLockOptions): Promise { - const list = Array.isArray(keys) ? keys : [keys]; + const list = this.getUniqueKeys(Array.isArray(keys) ? keys : [keys]); + + if (list.length === 0) { + return; + } await this.storage.update( this.key, bucketValue => { - const bucket: Partial = bucketValue && typeof bucketValue === "object" ? bucketValue : {}; - - if (Object.keys(bucket).length === 0) { - return bucket; - } - - const next = {...bucket}; + const bucket = this.decodeBucket(bucketValue); + const nextBucket = copyRecord(bucket); let changed = false; for (const currentKey of list) { - if (currentKey in next) { - delete next[currentKey]; + if (hasOwn(nextBucket, currentKey)) { + delete nextBucket[currentKey]; changed = true; } } if (!changed) { - return bucket; + return bucketValue; } - return Object.keys(next).length === 0 ? undefined : next; + return Object.keys(nextBucket).length === 0 ? undefined : nextBucket; }, options ); @@ -111,42 +306,82 @@ export default class MonoStorage imple await this.storage.remove(this.key, options); } - public watch(options: StorageWatchOptions): () => void { - return this.storage.watch({ - [this.key]: (newValue: Partial | undefined, oldValue: Partial | undefined) => { - const newObj: Partial = newValue && typeof newValue === "object" ? newValue : {}; - const oldObj: Partial = oldValue && typeof oldValue === "object" ? oldValue : {}; + public watch(watcher: StorageWatchOptions): () => void { + return watchChanges(callback => this.subscribe(callback), watcher); + } - const keys = new Set([...Object.keys(oldObj), ...Object.keys(newObj)]); + public subscribe(callback: StorageSubscriber): () => void { + let disposed = false; + let unsubscribeStorage: () => void = () => undefined; - if (typeof options === "function") { - for (const key of keys) { - const newValueByKey = newObj[key]; - const oldValueByKey = oldObj[key]; + const dispose = (): void => { + if (disposed) { + return; + } - if (!isEqual(newValueByKey, oldValueByKey)) { - options(newValueByKey, oldValueByKey, key); - } - } + disposed = true; + unsubscribeStorage(); + }; - return; - } + unsubscribeStorage = this.storage.subscribe(changes => { + if (disposed || !hasOwn(changes, this.key)) { + return; + } - for (const key of keys) { - const cb = options[key]; + const bucketChange = changes[this.key]; - if (!cb) { - continue; - } + if (!bucketChange) { + return; + } - const n = (newObj as any)[key]; - const o = (oldObj as any)[key]; + try { + const newBucket = this.decodeBucket(bucketChange.newValue); + const oldBucket = this.decodeBucket(bucketChange.oldValue); + const logicalChanges = this.diffBuckets(newBucket, oldBucket); - if (!isEqual(n, o)) { - cb(n, o); - } + if (!disposed && Object.keys(logicalChanges).length > 0) { + invokeCallback(() => callback(logicalChanges)); } - }, - } as unknown as StorageWatchOptions>>); + } catch (error) { + if (!disposed) { + dispose(); + scheduleUnhandledError(error); + } + } + }); + + return dispose; + } + + private diffBuckets(next: Partial, previous: Partial): StorageChanges { + const keys = new Set([...Object.keys(previous), ...Object.keys(next)]); + const changes = createRecord>(); + + for (const key of keys) { + const newValue = hasOwn(next, key) ? next[key] : undefined; + const oldValue = hasOwn(previous, key) ? previous[key] : undefined; + + if (isEqual(newValue, oldValue)) { + continue; + } + + setRecordValue(changes, key, {newValue, oldValue}); + } + + return changes; + } + + private getUniqueKeys(keys: readonly KP[]): KP[] { + const uniqueKeys = new Map(); + + for (const currentKey of keys) { + const normalizedKey = currentKey.toString(); + + if (!uniqueKeys.has(normalizedKey)) { + uniqueKeys.set(normalizedKey, currentKey); + } + } + + return [...uniqueKeys.values()]; } } diff --git a/src/providers/SecureStorage.test.ts b/src/providers/SecureStorage.test.ts index 5f8867a..fa361e1 100644 --- a/src/providers/SecureStorage.test.ts +++ b/src/providers/SecureStorage.test.ts @@ -1,5 +1,8 @@ import MonoStorage from "./MonoStorage"; import SecureStorage from "./SecureStorage"; +import Storage from "./Storage"; +import {StorageCorruptionError} from "../errors"; +import {captureUnhandledErrors, flushMacrotask} from "../../tests/helpers/async"; const hasArea = (name: keyof typeof chrome.storage) => { const area = (chrome.storage as any)[name]; @@ -27,10 +30,73 @@ const securedStorageWithSecureKey = new SecureStorage({ secureKey: "customSecureKey", }); +interface SecureBatchState { + accessToken?: string; + refreshToken?: string; + attempts?: number; +} + +interface SecureSeparatorState { + accessToken?: string; + "refresh:Token"?: string; +} + beforeEach(async () => { + global.resetStorageChangeListeners(); await clearAllAreas(); }); +describe("namespace separator validation", () => { + test("rejects a namespace containing the secure storage separator", () => { + expect(() => new SecureStorage({namespace: "auth:tokens"})).toThrow( + 'Storage namespace "auth:tokens" must not contain the namespace separator ":".' + ); + }); + + test("rejects separator keys in every operation before crypto or native I/O", async () => { + const storage = new SecureStorage(); + const singleUpdater = jest.fn(() => "updated"); + const batchUpdater = jest.fn(() => ({accessToken: "updated"})); + const encryptSpy = crypto.subtle.encrypt as jest.Mock; + const decryptSpy = crypto.subtle.decrypt as jest.Mock; + const getSpy = chrome.storage.local.get as jest.Mock; + const setSpy = chrome.storage.local.set as jest.Mock; + const removeSpy = chrome.storage.local.remove as jest.Mock; + const addListenerSpy = chrome.storage.onChanged.addListener as jest.Mock; + encryptSpy.mockClear(); + decryptSpy.mockClear(); + getSpy.mockClear(); + setSpy.mockClear(); + removeSpy.mockClear(); + addListenerSpy.mockClear(); + + await expect(storage.get("refresh:Token")).rejects.toThrow(TypeError); + await expect(storage.set("refresh:Token", "refresh")).rejects.toThrow(TypeError); + await expect(storage.update("refresh:Token", singleUpdater)).rejects.toThrow(TypeError); + await expect(storage.remove("refresh:Token")).rejects.toThrow(TypeError); + await expect(storage.get(["accessToken", "refresh:Token"] as const)).rejects.toThrow(TypeError); + await expect(storage.set({accessToken: "access", "refresh:Token": "refresh"})).rejects.toThrow( + TypeError + ); + await expect( + storage.update(["accessToken", "refresh:Token"] as const, batchUpdater) + ).rejects.toThrow(TypeError); + await expect(storage.remove(["accessToken", "refresh:Token"])).rejects.toThrow(TypeError); + expect(() => storage.watch({"refresh:Token": jest.fn()})).toThrow( + 'Storage key "refresh:Token" must not contain the namespace separator ":".' + ); + + expect(singleUpdater).not.toHaveBeenCalled(); + expect(batchUpdater).not.toHaveBeenCalled(); + expect(encryptSpy).not.toHaveBeenCalled(); + expect(decryptSpy).not.toHaveBeenCalled(); + expect(getSpy).not.toHaveBeenCalled(); + expect(setSpy).not.toHaveBeenCalled(); + expect(removeSpy).not.toHaveBeenCalled(); + expect(addListenerSpy).not.toHaveBeenCalled(); + }); +}); + test("getAll method - returns all values from current namespace", async () => { await securedStorage.set("a", 1); await securedStorage.set("b", 2); @@ -59,13 +125,120 @@ test("clear method - removes all keys from current namespace", async () => { expect(resultWithNamespace).toEqual({c: 3, d: 4}); }); +describe("strict physical key codec", () => { + test("separates an unnamespaced SecureStorage from Storage namespace secure", async () => { + const flat = new Storage<{theme?: string}>({namespace: "secure"}); + const encrypted = new SecureStorage<{theme?: string}>(); + + await flat.set("theme", "flat"); + await encrypted.set("theme", "encrypted"); + + const raw = await getAllFromArea("local"); + expect(raw["secure:theme"]).toBe("flat"); + expect(typeof raw["secure::theme"]).toBe("string"); + await expect(flat.get("theme")).resolves.toBe("flat"); + await expect(encrypted.get("theme")).resolves.toBe("encrypted"); + + const flatCallback = jest.fn(); + const encryptedCallback = jest.fn(); + const unsubscribeFlat = flat.subscribe(flatCallback); + const unsubscribeEncrypted = encrypted.subscribe(encryptedCallback); + + try { + global.simulateStorageChange({ + storage: flat, + key: "theme", + oldValue: "flat", + newValue: "changed-flat", + }); + await flushMacrotask(); + + expect(flatCallback).toHaveBeenCalledTimes(1); + expect(encryptedCallback).not.toHaveBeenCalled(); + + await global.simulateSecureStorageChange({ + storage: encrypted, + key: "theme", + oldValue: "encrypted", + newValue: "changed-encrypted", + }); + + expect(flatCallback).toHaveBeenCalledTimes(1); + expect(encryptedCallback).toHaveBeenCalledTimes(1); + } finally { + unsubscribeFlat(); + unsubscribeEncrypted(); + } + }); + + test("ignores legacy and malformed keys in getAll, events, and clear", async () => { + const storage = new SecureStorage<{theme?: string}>(); + const canonical = await (storage as any).encrypt("canonical"); + const ignored = await (storage as any).encrypt("ignored"); + + await chrome.storage.local.set({ + "secure::theme": canonical, + "secure:legacy": ignored, + "secure:::extra": ignored, + "secure:other:theme": ignored, + }); + + await expect(storage.getAll()).resolves.toEqual({theme: "canonical"}); + + const callback = jest.fn(); + const unsubscribe = storage.subscribe(callback); + + try { + for (const fullKey of ["secure:legacy", "secure:::extra", "secure:other:theme"]) { + global.simulateStorageChanges({ + storage: {getFullKey: () => fullKey}, + changes: {theme: {oldValue: ignored, newValue: ignored}}, + }); + } + + await flushMacrotask(); + expect(callback).not.toHaveBeenCalled(); + } finally { + unsubscribe(); + } + + await storage.clear(); + + await expect(getAllFromArea("local")).resolves.toEqual({ + "secure:legacy": ignored, + "secure:::extra": ignored, + "secure:other:theme": ignored, + }); + }); + + test("reads an unchanged ciphertext after a manual raw legacy migration", async () => { + const storage = new SecureStorage<{theme?: string}>(); + const ciphertext = await (storage as any).encrypt("dark"); + + await chrome.storage.local.set({"secure:theme": ciphertext}); + + await expect(storage.get("theme")).resolves.toBeUndefined(); + await expect(storage.getAll()).resolves.toEqual({}); + + const beforeMigration = await getAllFromArea("local"); + expect(beforeMigration["secure::theme"]).toBeUndefined(); + + await chrome.storage.local.set({"secure::theme": beforeMigration["secure:theme"]}); + await chrome.storage.local.remove("secure:theme"); + + const afterMigration = await getAllFromArea("local"); + expect(afterMigration["secure::theme"]).toBe(ciphertext); + expect(afterMigration["secure:theme"]).toBeUndefined(); + await expect(storage.get("theme")).resolves.toBe("dark"); + }); +}); + describe("set/get methods with different type of value", () => { test.each([ ["string", "hello"], ["number", 42], ["boolean", true], ["null", null], - ["undefined", undefined], ["object", {a: 1, b: true}], ["array", [1, 2, 3]], ])("set/get with %s", async (_, value) => { @@ -74,7 +247,7 @@ describe("set/get methods with different type of value", () => { const encryptedValue = await global.storageLocalGet("key", securedStorage); const decryptedValue = (await securedStorage.getAll())["key"]; - value !== undefined && expect(encryptedValue).not.toEqual(value); + expect(encryptedValue).not.toEqual(value); expect(decryptedValue).toEqual(value); }); }); @@ -133,6 +306,144 @@ describe("update method", () => { }); }); +describe("batch overloads", () => { + test("batch set encrypts namespaced values into one native write and batch get decrypts one native read", async () => { + const storage = new SecureStorage({namespace: "auth"}); + const setSpy = chrome.storage.local.set as jest.Mock; + setSpy.mockClear(); + + await storage.set({accessToken: "access", refreshToken: "refresh"}); + + expect(setSpy).toHaveBeenCalledTimes(1); + + const setPayload = setSpy.mock.calls[0]?.[0]; + expect(Object.keys(setPayload)).toEqual(["secure:auth:accessToken", "secure:auth:refreshToken"]); + expect(typeof setPayload["secure:auth:accessToken"]).toBe("string"); + expect(typeof setPayload["secure:auth:refreshToken"]).toBe("string"); + expect(setPayload["secure:auth:accessToken"]).not.toBe("access"); + expect(setPayload["secure:auth:refreshToken"]).not.toBe("refresh"); + + const getSpy = chrome.storage.local.get as jest.Mock; + getSpy.mockClear(); + + await expect(storage.get(["accessToken", "refreshToken", "attempts"] as const)).resolves.toEqual({ + accessToken: "access", + refreshToken: "refresh", + }); + expect(getSpy).toHaveBeenCalledTimes(1); + expect(getSpy).toHaveBeenCalledWith( + ["secure:auth:accessToken", "secure:auth:refreshToken", "secure:auth:attempts"], + expect.any(Function) + ); + }); + + test("batch set performs no storage write when any encryption fails", async () => { + const storage = new SecureStorage(); + const encryptMock = crypto.subtle.encrypt as jest.Mock; + const defaultImplementation = encryptMock.getMockImplementation(); + const setSpy = chrome.storage.local.set as jest.Mock; + + expect(defaultImplementation).toBeDefined(); + encryptMock.mockImplementationOnce(defaultImplementation as (...args: any[]) => any); + encryptMock.mockRejectedValueOnce(new Error("encryption failed")); + setSpy.mockClear(); + + await expect(storage.set({accessToken: "access", refreshToken: "refresh"})).rejects.toThrow( + "encryption failed" + ); + + expect(setSpy).not.toHaveBeenCalled(); + await expect(storage.get(["accessToken", "refreshToken"] as const)).resolves.toEqual({}); + }); + + test("batch set rejects undefined before encrypting any value", async () => { + const storage = new SecureStorage(); + const encryptSpy = crypto.subtle.encrypt as jest.Mock; + const setSpy = chrome.storage.local.set as jest.Mock; + encryptSpy.mockClear(); + setSpy.mockClear(); + + await expect( + storage.set({accessToken: "access", refreshToken: undefined} as Partial) + ).rejects.toThrow(TypeError); + + expect(encryptSpy).not.toHaveBeenCalled(); + expect(setSpy).not.toHaveBeenCalled(); + }); + + test("batch update compares decrypted values and encrypts only changed keys before one native write", async () => { + const storage = new SecureStorage({namespace: "auth"}); + await storage.set({accessToken: "old", refreshToken: "same", attempts: 1}); + + const encryptSpy = crypto.subtle.encrypt as jest.Mock; + const setSpy = chrome.storage.local.set as jest.Mock; + const removeSpy = chrome.storage.local.remove as jest.Mock; + encryptSpy.mockClear(); + setSpy.mockClear(); + removeSpy.mockClear(); + + const result = await storage.update( + ["accessToken", "refreshToken", "attempts"] as const, + prev => ({ + accessToken: `${prev.accessToken}:next`, + refreshToken: "same", + attempts: 2, + }) + ); + + expect(result).toEqual({accessToken: "old:next", refreshToken: "same", attempts: 2}); + expect(encryptSpy).toHaveBeenCalledTimes(2); + expect(setSpy).toHaveBeenCalledTimes(1); + expect(Object.keys(setSpy.mock.calls[0]?.[0])).toEqual(["secure:auth:accessToken", "secure:auth:attempts"]); + expect(removeSpy).not.toHaveBeenCalled(); + }); + + test("batch update does not encrypt or write an equal patch", async () => { + const storage = new SecureStorage(); + await storage.set({accessToken: "same", attempts: 1}); + + const encryptSpy = crypto.subtle.encrypt as jest.Mock; + const setSpy = chrome.storage.local.set as jest.Mock; + const removeSpy = chrome.storage.local.remove as jest.Mock; + encryptSpy.mockClear(); + setSpy.mockClear(); + removeSpy.mockClear(); + + await expect( + storage.update(["accessToken", "attempts"] as const, () => ({accessToken: "same", attempts: 1})) + ).resolves.toEqual({accessToken: "same", attempts: 1}); + + expect(encryptSpy).not.toHaveBeenCalled(); + expect(setSpy).not.toHaveBeenCalled(); + expect(removeSpy).not.toHaveBeenCalled(); + }); + + test("batch update encrypts writes before removing deleted keys in a mixed patch", async () => { + const storage = new SecureStorage({namespace: "auth"}); + await storage.set({accessToken: "old", refreshToken: "remove"}); + + const encryptSpy = crypto.subtle.encrypt as jest.Mock; + const setSpy = chrome.storage.local.set as jest.Mock; + const removeSpy = chrome.storage.local.remove as jest.Mock; + encryptSpy.mockClear(); + setSpy.mockClear(); + removeSpy.mockClear(); + + const result = await storage.update(["accessToken", "refreshToken"] as const, () => ({ + accessToken: "new", + refreshToken: undefined, + })); + + expect(result).toEqual({accessToken: "new"}); + expect(encryptSpy).toHaveBeenCalledTimes(1); + expect(setSpy).toHaveBeenCalledTimes(1); + expect(Object.keys(setSpy.mock.calls[0]?.[0])).toEqual(["secure:auth:accessToken"]); + expect(removeSpy).toHaveBeenCalledTimes(1); + expect(removeSpy).toHaveBeenCalledWith(["secure:auth:refreshToken"], expect.any(Function)); + expect(setSpy.mock.invocationCallOrder[0]).toBeLessThan(removeSpy.mock.invocationCallOrder[0]); + }); +}); + describe("remove method", () => { test("deletes the key without namespace", async () => { await securedStorage.set("theme", "dark"); @@ -147,9 +458,308 @@ describe("remove method", () => { const result = (await securedStorageWithNamespace.getAll())["theme"]; expect(result).toBeUndefined(); }); + + test("remove and clear recover corrupted values without decrypting them", async () => { + const storage = new SecureStorage<{first?: string; second?: string}>({namespace: "recovery"}); + const firstKey = (storage as any).getFullKey("first"); + const secondKey = (storage as any).getFullKey("second"); + await chrome.storage.local.set({[firstKey]: "corrupt:first", [secondKey]: "corrupt:second"}); + + const decryptSpy = crypto.subtle.decrypt as jest.Mock; + decryptSpy.mockClear(); + + await storage.remove("first"); + expect(await global.storageLocalGet("first", storage)).toBeUndefined(); + expect(decryptSpy).not.toHaveBeenCalled(); + + await storage.clear(); + expect(await global.storageLocalGet("second", storage)).toBeUndefined(); + expect(decryptSpy).not.toHaveBeenCalled(); + }); +}); + +describe("corrupted values", () => { + test("single, batch, and getAll reads reject instead of returning defaults", async () => { + const storage = new SecureStorage({namespace: "corrupt"}); + const fullKey = (storage as any).getFullKey("accessToken"); + await chrome.storage.local.set({[fullKey]: ""}); + + await expect(storage.get("accessToken")).rejects.toBeInstanceOf(StorageCorruptionError); + await expect(storage.get(["accessToken", "attempts"] as const)).rejects.toBeInstanceOf( + StorageCorruptionError + ); + await expect(storage.getAll()).rejects.toBeInstanceOf(StorageCorruptionError); + }); + + test("a present undefined storage property is corruption while a missing property is absent", async () => { + const storage = new SecureStorage({namespace: "corrupt-undefined"}); + const fullKey = (storage as any).getFullKey("accessToken"); + const getSpy = chrome.storage.local.get as jest.Mock; + + getSpy + .mockImplementationOnce((_keys: unknown, callback: (items: Record) => void) => { + callback({[fullKey]: undefined}); + }) + .mockImplementationOnce((_keys: unknown, callback: (items: Record) => void) => { + callback({[fullKey]: undefined}); + }) + .mockImplementationOnce((_keys: unknown, callback: (items: Record) => void) => { + callback({[fullKey]: undefined}); + }); + + await expect(storage.get("accessToken")).rejects.toBeInstanceOf(StorageCorruptionError); + await expect(storage.get(["accessToken"] as const)).rejects.toBeInstanceOf(StorageCorruptionError); + await expect(storage.getAll()).rejects.toBeInstanceOf(StorageCorruptionError); + + await expect(storage.get("accessToken")).resolves.toBeUndefined(); + }); }); -describe("watch method", () => { +describe("watch and subscribe methods", () => { + test("event decoding ignores inherited change sides", async () => { + const storage = new SecureStorage({namespace: "event-own-fields"}); + const encryptedOldValue = await (storage as any).encrypt("old"); + const callback = jest.fn(); + const errors = captureUnhandledErrors(); + const unsubscribe = storage.subscribe(callback); + const change = Object.create({newValue: {corrupted: true}}) as chrome.storage.StorageChange; + Object.defineProperty(change, "oldValue", { + enumerable: true, + value: encryptedOldValue, + }); + + try { + global.simulateStorageChanges({storage, changes: {accessToken: change}}); + await flushMacrotask(); + + expect(callback).toHaveBeenCalledWith({ + accessToken: {oldValue: "old", newValue: undefined}, + }); + expect(errors.pending).toBe(0); + } finally { + unsubscribe(); + errors.restore(); + } + }); + + test("event decoding treats omitted and own undefined sides as absent", async () => { + const storage = new SecureStorage({namespace: "event-absence"}); + const encryptedValue = await (storage as any).encrypt("value"); + const callback = jest.fn(); + const unsubscribe = storage.subscribe(callback); + + global.simulateStorageChanges({ + storage, + changes: {accessToken: {newValue: encryptedValue}}, + }); + global.simulateStorageChanges({ + storage, + changes: {accessToken: {oldValue: encryptedValue}}, + }); + global.simulateStorageChanges({ + storage, + changes: {accessToken: {oldValue: undefined, newValue: encryptedValue}}, + }); + global.simulateStorageChanges({ + storage, + changes: {accessToken: {oldValue: encryptedValue, newValue: undefined}}, + }); + + await flushMacrotask(); + + expect(callback.mock.calls).toEqual([ + [{accessToken: {oldValue: undefined, newValue: "value"}}], + [{accessToken: {oldValue: "value", newValue: undefined}}], + [{accessToken: {oldValue: undefined, newValue: "value"}}], + [{accessToken: {oldValue: "value", newValue: undefined}}], + ]); + + unsubscribe(); + }); + + test("subscribe decrypts one native multi-key event into one logical change map", async () => { + const storage = new SecureStorage({namespace: "auth"}); + const callback = jest.fn(); + const unsubscribe = storage.subscribe(callback); + + await global.simulateSecureStorageChanges({ + storage, + changes: { + accessToken: {oldValue: "old", newValue: "new"}, + attempts: {oldValue: 1, newValue: 2}, + }, + }); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith({ + accessToken: {oldValue: "old", newValue: "new"}, + attempts: {oldValue: 1, newValue: 2}, + }); + + unsubscribe(); + }); + + test("subscribe filters changes whose decrypted values are equal", async () => { + const storage = new SecureStorage(); + const callback = jest.fn(); + const unsubscribe = storage.subscribe(callback); + + await global.simulateSecureStorageChanges({ + storage, + changes: { + accessToken: {oldValue: "same", newValue: "same"}, + attempts: {oldValue: 1, newValue: 2}, + }, + }); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith({attempts: {oldValue: 1, newValue: 2}}); + unsubscribe(); + }); + + test("serializes decrypt formatting while not waiting for subscriber promises", async () => { + const storage = new SecureStorage<{value?: string}>({namespace: "ordered"}); + let releaseFirst: ((value: string) => void) | undefined; + const firstValue = new Promise(resolve => { + releaseFirst = resolve; + }); + const decryptSpy = jest.spyOn(storage as any, "decrypt").mockImplementation((value: unknown) => { + if (value === "slow") { + return firstValue; + } + + return Promise.resolve(String(value)); + }); + const neverSettles = new Promise(() => undefined); + const callback = jest.fn().mockReturnValueOnce(neverSettles).mockReturnValue(undefined); + const unsubscribe = storage.subscribe(callback); + + global.simulateStorageChange({storage, key: "value", oldValue: undefined, newValue: "slow"}); + global.simulateStorageChange({storage, key: "value", oldValue: "slow", newValue: "fast"}); + + await Promise.resolve(); + expect(decryptSpy).toHaveBeenCalledTimes(1); + + releaseFirst?.("first"); + await flushMacrotask(); + + expect(callback).toHaveBeenNthCalledWith(1, {value: {oldValue: undefined, newValue: "first"}}); + expect(callback).toHaveBeenNthCalledWith(2, {value: {oldValue: "first", newValue: "fast"}}); + + unsubscribe(); + decryptSpy.mockRestore(); + }); + + test("unsubscribe during decrypt prevents late callback and future delivery", async () => { + const storage = new SecureStorage<{value?: string}>({namespace: "unsubscribe-pending"}); + let releaseDecrypt: ((value: string) => void) | undefined; + const pendingDecrypt = new Promise(resolve => { + releaseDecrypt = resolve; + }); + const decryptSpy = jest.spyOn(storage as any, "decrypt").mockImplementation((value: unknown) => { + if (value === "pending") { + return pendingDecrypt; + } + + return Promise.resolve(String(value)); + }); + const callback = jest.fn(); + const unsubscribe = storage.subscribe(callback); + + try { + global.simulateStorageChange({ + storage, + key: "value", + oldValue: undefined, + newValue: "pending", + }); + + await Promise.resolve(); + expect(decryptSpy).toHaveBeenCalledTimes(1); + + unsubscribe(); + releaseDecrypt?.("decoded"); + await flushMacrotask(); + + global.simulateStorageChange({ + storage, + key: "value", + oldValue: "decoded", + newValue: "future", + }); + await flushMacrotask(); + + expect(callback).not.toHaveBeenCalled(); + expect(decryptSpy).toHaveBeenCalledTimes(1); + } finally { + unsubscribe(); + decryptSpy.mockRestore(); + } + }); + + test("a keyed watch handler can unsubscribe before later handlers in the same event", async () => { + const storage = new SecureStorage<{theme?: string; volume?: number}>({namespace: "watch-unsubscribe"}); + const volumeCallback = jest.fn(); + let unsubscribe: () => void = () => undefined; + const themeCallback = jest.fn(() => unsubscribe()); + + unsubscribe = storage.watch({theme: themeCallback, volume: volumeCallback}); + + try { + await global.simulateSecureStorageChanges({ + storage, + changes: { + theme: {oldValue: "light", newValue: "dark"}, + volume: {oldValue: 10, newValue: 20}, + }, + }); + + expect(themeCallback).toHaveBeenCalledTimes(1); + expect(themeCallback).toHaveBeenCalledWith("dark", "light"); + expect(volumeCallback).not.toHaveBeenCalled(); + } finally { + unsubscribe(); + } + }); + + test("corruption in an unrelated key kills watch without partial or future delivery", async () => { + const storage = new SecureStorage<{theme?: string; unrelated?: string}>({namespace: "event-corruption"}); + const themeCallback = jest.fn(); + const errors = captureUnhandledErrors(); + const unsubscribe = storage.watch({theme: themeCallback}); + + try { + const oldTheme = await (storage as any).encrypt("light"); + const newTheme = await (storage as any).encrypt("dark"); + + global.simulateStorageChanges({ + storage, + changes: { + theme: {oldValue: oldTheme, newValue: newTheme}, + unrelated: {oldValue: null, newValue: {corrupted: true}}, + }, + }); + await flushMacrotask(); + + expect(themeCallback).not.toHaveBeenCalled(); + expect(errors.pending).toBe(1); + expect(() => errors.runNext()).toThrow(StorageCorruptionError); + + await global.simulateSecureStorageChange({ + storage, + key: "theme", + oldValue: "dark", + newValue: "later", + }); + + expect(themeCallback).not.toHaveBeenCalled(); + expect(errors.pending).toBe(0); + } finally { + unsubscribe(); + errors.restore(); + } + }); + test("calls specific key callback on change", async () => { const keyCallback = jest.fn(); @@ -225,14 +835,12 @@ describe("watch method", () => { expect(globalCallback).toHaveBeenCalledWith("dark", "light", "theme"); }); - test("ignores non-string storage change values without logging watch errors", async () => { + test("terminates a watch and schedules an uncaught error for a corrupted change", async () => { const keyCallback = jest.fn(); - const globalCallback = jest.fn(); - const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + const errors = captureUnhandledErrors(); try { securedStorage.watch({theme: keyCallback}); - securedStorage.watch(globalCallback); global.simulateStorageChange({ storage: securedStorage, @@ -240,14 +848,29 @@ describe("watch method", () => { oldValue: null, newValue: {theme: "dark"}, }); + global.simulateStorageChange({ + storage: securedStorage, + key: "theme", + oldValue: "ignored", + newValue: "ignored-too", + }); - await new Promise(resolve => setTimeout(resolve)); + await flushMacrotask(); - expect(keyCallback).toHaveBeenCalledWith(undefined, undefined); - expect(globalCallback).toHaveBeenCalledWith(undefined, undefined, "theme"); - expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(keyCallback).not.toHaveBeenCalled(); + expect(errors.pending).toBe(1); + expect(() => errors.runNext()).toThrow(StorageCorruptionError); + + global.simulateStorageChange({ + storage: securedStorage, + key: "theme", + oldValue: "still-ignored", + newValue: "still-ignored-too", + }); + await flushMacrotask(); + expect(keyCallback).not.toHaveBeenCalled(); } finally { - consoleErrorSpy.mockRestore(); + errors.restore(); } }); }); @@ -273,6 +896,39 @@ describe("static factory methods", () => { }); describe("Area shortcuts", () => { + test("SecureStorage.Local() forwards secureKey to key derivation", async () => { + const digestSpy = crypto.subtle.digest as jest.Mock; + digestSpy.mockClear(); + const storage = SecureStorage.Local<{theme?: string}>({ + namespace: "factory-secure-key", + secureKey: "ForwardedSecureKey", + }); + + await storage.set("theme", "dark"); + + expect(digestSpy).toHaveBeenCalledTimes(1); + expect(digestSpy).toHaveBeenCalledWith( + "SHA-256", + new TextEncoder().encode("ForwardedSecureKey") + ); + }); + + test("SecureStorage.Session() writes encrypted data to the session area", async () => { + if (!hasArea("session")) { + return; + } + + const storage = SecureStorage.Session<{token?: string}>({secureKey: "SessionSecureKey"}); + + await storage.set("token", "session-token"); + + const sessionValues = await getAllFromArea("session"); + expect(typeof sessionValues["secure::token"]).toBe("string"); + + const localValues = await getAllFromArea("local"); + expect(localValues["secure::token"]).toBeUndefined(); + }); + test("SecureStorage.Local() returns secure provider and MonoStorage with key", async () => { const s = SecureStorage.Local(); expect(s).toBeInstanceOf(SecureStorage); diff --git a/src/providers/SecureStorage.ts b/src/providers/SecureStorage.ts index 08f99f5..2211dec 100644 --- a/src/providers/SecureStorage.ts +++ b/src/providers/SecureStorage.ts @@ -1,16 +1,105 @@ -import AbstractStorage, {type StorageOptions} from "./AbstractStorage"; -import type {StorageLockOptions, StorageState, StorageWatchOptions} from "../types"; +import {STORAGE_KEY_SEPARATOR} from "../constants"; +import {StorageCorruptionError} from "../errors"; +import {createRecord, hasOwn, setRecordValue} from "../utils"; +import AbstractStorage, { + type AreaOptions, + type FactoryOptions, + type StaticMake, + type StorageOptions, +} from "./AbstractStorage"; +import type {StorageLockOptions, StorageProvider, StorageState} from "../types"; type StorageChange = chrome.storage.StorageChange; +const ABSENT = Symbol("absent secure storage value"); + export interface SecureStorageOptions extends StorageOptions { secureKey?: string; } +type SecureStorageFactoryOptions = SecureStorageOptions & {key?: string}; +type SecureStorageAreaOptions = Omit; + export default class SecureStorage extends AbstractStorage { private readonly secureKey: string; private cryptoKey: CryptoKey | null = null; + private cryptoKeyPromise: Promise | null = null; + + public static override make(options?: SecureStorageFactoryOptions): StorageProvider; + public static override make< + S extends StorageState, + O extends StorageOptions = StorageOptions, + C extends new ( + options?: O + ) => StorageProvider = new ( + options?: O + ) => StorageProvider, + >(this: C, options?: FactoryOptions): StorageProvider; + public static override make(options?: any): StorageProvider { + // biome-ignore lint/complexity/noThisInStatic: Preserve polymorphic static factory dispatch. + return super.make(options); + } + + public static override Local(options?: SecureStorageAreaOptions): StorageProvider; + public static override Local< + S extends StorageState, + O extends StorageOptions = StorageOptions, + C extends new ( + options?: O + ) => StorageProvider = new ( + options?: O + ) => StorageProvider, + >(this: C & {make: StaticMake}, options?: AreaOptions): StorageProvider; + public static override Local(options?: any): StorageProvider { + // biome-ignore lint/complexity/noThisInStatic: Preserve polymorphic static factory dispatch. + return this.make({...options, area: "local"}); + } + + public static override Session(options?: SecureStorageAreaOptions): StorageProvider; + public static override Session< + S extends StorageState, + O extends StorageOptions = StorageOptions, + C extends new ( + options?: O + ) => StorageProvider = new ( + options?: O + ) => StorageProvider, + >(this: C & {make: StaticMake}, options?: AreaOptions): StorageProvider; + public static override Session(options?: any): StorageProvider { + // biome-ignore lint/complexity/noThisInStatic: Preserve polymorphic static factory dispatch. + return this.make({...options, area: "session"}); + } + + public static override Sync(options?: SecureStorageAreaOptions): StorageProvider; + public static override Sync< + S extends StorageState, + O extends StorageOptions = StorageOptions, + C extends new ( + options?: O + ) => StorageProvider = new ( + options?: O + ) => StorageProvider, + >(this: C & {make: StaticMake}, options?: AreaOptions): StorageProvider; + public static override Sync(options?: any): StorageProvider { + // biome-ignore lint/complexity/noThisInStatic: Preserve polymorphic static factory dispatch. + return this.make({...options, area: "sync"}); + } + + public static override Managed(options?: SecureStorageAreaOptions): StorageProvider; + public static override Managed< + S extends StorageState, + O extends StorageOptions = StorageOptions, + C extends new ( + options?: O + ) => StorageProvider = new ( + options?: O + ) => StorageProvider, + >(this: C & {make: StaticMake}, options?: AreaOptions): StorageProvider; + public static override Managed(options?: any): StorageProvider { + // biome-ignore lint/complexity/noThisInStatic: Preserve polymorphic static factory dispatch. + return this.make({...options, area: "managed"}); + } constructor({secureKey, ...options}: SecureStorageOptions = {}) { super(options); @@ -23,16 +112,24 @@ export default class SecureStorage extends AbstractStora return this.cryptoKey; } - const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(this.secureKey)); + if (!this.cryptoKeyPromise) { + this.cryptoKeyPromise = (async () => { + const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(this.secureKey)); - const key = await crypto.subtle.importKey("raw", hash.slice(0, 32), {name: "AES-GCM"}, false, [ - "encrypt", - "decrypt", - ]); + return await crypto.subtle.importKey("raw", hash.slice(0, 32), {name: "AES-GCM"}, false, [ + "encrypt", + "decrypt", + ]); + })(); + } - this.cryptoKey = key; + try { + this.cryptoKey = await this.cryptoKeyPromise; - return key; + return this.cryptoKey; + } finally { + this.cryptoKeyPromise = null; + } } private async encrypt(data: any): Promise { @@ -65,70 +162,135 @@ export default class SecureStorage extends AbstractStora return JSON.parse(new TextDecoder().decode(decrypted)); } - protected async setUnlocked(key: K, value: T[K]): Promise { - if (value === undefined) { - return; + private async decodeStoredValue(value: unknown | typeof ABSENT, key: PropertyKey): Promise { + if (value === ABSENT) { + return undefined; + } + + if (typeof value !== "string" || value.length === 0) { + throw new StorageCorruptionError( + "SecureStorage", + key, + new TypeError("Encrypted storage value must be a non-empty string.") + ); } + try { + return await this.decrypt(value); + } catch (error) { + throw new StorageCorruptionError("SecureStorage", key, error); + } + } + + private async decodeChangeSide( + changes: StorageChange, + side: "newValue" | "oldValue", + key: PropertyKey + ): Promise { + const value = hasOwn(changes, side) ? changes[side] : ABSENT; + + return await this.decodeStoredValue(value === undefined ? ABSENT : value, key); + } + + protected async setUnlocked(key: K, value: T[K]): Promise { const encryptedValue = await this.encrypt(value); await super.setUnlocked(key, encryptedValue as T[K]); } + protected async setBatchUnlocked(values: Partial): Promise { + const encryptedEntries = await Promise.all( + (Object.keys(values) as (keyof T)[]).map(async key => { + const value = values[key]; + return [key, await this.encrypt(value)] as const; + }) + ); + const encryptedValues = createRecord>(); + + for (const [key, value] of encryptedEntries) { + setRecordValue(encryptedValues, key, value as T[typeof key]); + } + + await super.setBatchUnlocked(encryptedValues); + } + protected async getUnlocked(key: K): Promise { - const encryptedValue = (await super.getUnlocked(key)) as string; + const fullKey = this.getFullKey(key); + const encryptedValues = await this.getStoredItems(fullKey); + const encryptedValue = hasOwn(encryptedValues, fullKey) ? encryptedValues[fullKey] : ABSENT; - return encryptedValue ? this.decrypt(encryptedValue) : undefined; + return await this.decodeStoredValue(encryptedValue, key); + } + + protected async getBatchUnlocked(keys: readonly K[]): Promise>> { + const encryptedValues = await super.getBatchUnlocked(keys); + const decryptedEntries = await Promise.all( + (Object.keys(encryptedValues) as K[]).map(async key => { + const encryptedValue = encryptedValues[key]; + return [key, await this.decodeStoredValue(encryptedValue, key)] as const; + }) + ); + const decryptedValues = createRecord>>(); + + for (const [key, value] of decryptedEntries) { + setRecordValue(decryptedValues, key, value as T[K]); + } + + return decryptedValues; } public async getAll(): Promise> { - const encryptedValues = await super.getAll(); + const encryptedValues = await this.getAllStoredValues(); + + const decryptedValues = createRecord>>(); - const decryptedValues: Partial> = {}; + const entries = await Promise.all( + Object.entries(encryptedValues as Record).map(async ([key, value]) => [ + key, + await this.decodeStoredValue(value, key), + ]) + ); - for (const [key, value] of Object.entries(encryptedValues as Record)) { - decryptedValues[key as keyof T] = value ? await this.decrypt(String(value)) : undefined; + for (const [key, value] of entries) { + setRecordValue(decryptedValues, key, value); } return decryptedValues as Partial; } public async clear(options?: StorageLockOptions): Promise { - const allValues = await super.getAll(); + const allValues = await this.getAllStoredValues(); await this.remove(Object.keys(allValues), options); } - protected isKeyValid(key: string): boolean { - if (!super.isKeyValid(key)) return false; - - return key.startsWith(`secure${this.separator}`); - } - - protected async handleChange

( - key: string, - changes: StorageChange, - options: StorageWatchOptions

- ): Promise { - const newValue = typeof changes.newValue === "string" ? await this.decrypt(changes.newValue) : undefined; - const oldValue = typeof changes.oldValue === "string" ? await this.decrypt(changes.oldValue) : undefined; + protected async formatChange

( + key: keyof P, + changes: StorageChange + ): Promise<{ + key: keyof P; + newValue: P[keyof P] | undefined; + oldValue: P[keyof P] | undefined; + }> { + const [newValue, oldValue] = await Promise.all([ + this.decodeChangeSide(changes, "newValue", key), + this.decodeChangeSide(changes, "oldValue", key), + ]); - await this.triggerChange(key, {newValue, oldValue}, options); + return {key, newValue, oldValue}; } protected getFullKey(key: keyof T): string { - const parts: string[] = ["secure"]; - - if (this.namespace) { - parts.push(this.namespace); - } + const logicalKey = this.toLogicalKey(key); - return [...parts, key.toString()].join(this.separator); + return ["secure", this.namespace ?? "", logicalKey].join(STORAGE_KEY_SEPARATOR); } - protected getNamespaceOfKey(key: string): string | undefined { - const fullKeyParts = key.split(this.separator); + protected decodeFullKey(fullKey: string): keyof T | null { + const parts = fullKey.split(STORAGE_KEY_SEPARATOR); - return fullKeyParts.length === 3 ? fullKeyParts[1] : undefined; + return parts.length === 3 && parts[0] === "secure" && parts[1] === (this.namespace ?? "") + ? (parts[2] as keyof T) + : null; } } diff --git a/src/providers/Storage.test.ts b/src/providers/Storage.test.ts index ee4175b..b38dd89 100644 --- a/src/providers/Storage.test.ts +++ b/src/providers/Storage.test.ts @@ -1,6 +1,8 @@ import MonoStorage from "./MonoStorage"; import Storage from "./Storage"; -import type {StorageLocker} from "../types"; +import {StoragePartialUpdateError} from "../errors"; +import type {StorageBatchUpdateOptions, StorageLocker} from "../types"; +import {captureUnhandledErrors, flushMacrotask} from "../../tests/helpers/async"; const hasArea = (name: keyof typeof chrome.storage) => { const area = (chrome.storage as any)[name]; @@ -25,7 +27,32 @@ const storage = new Storage(); const storageWithNamespace = new Storage({namespace}); const originalLocks = globalThis.navigator.locks; +interface BatchState { + a?: number; + b?: number; + c?: string; + missing?: boolean; +} + +interface PrototypeKeyState { + "__proto__"?: string; + safe?: string; +} + +interface PrototypeNamedState { + constructor?: string; + toString?: string; + valueOf?: string; +} + +interface SeparatorState { + good?: string; + "bad:key"?: string; + "user:profile"?: string; +} + beforeEach(async () => { + global.resetStorageChangeListeners(); await clearAllAreas(); }); @@ -75,7 +102,7 @@ test("update method - fails when Web Locks API is unavailable", async () => { const isolatedStorage = new Storage(); await expect(isolatedStorage.update("counter", prev => (prev ?? 0) + 1)).rejects.toThrow( - "Atomic storage update is unavailable: Web Locks API is not supported in this context." + "Lock-coordinated storage update is unavailable: Web Locks API is not supported in this context." ); }); @@ -240,6 +267,546 @@ describe("update method - no-op writes", () => { }); }); +describe("batch overloads", () => { + test("batch get reads requested namespaced keys in one native call and omits missing values", async () => { + const isolatedStorage = new Storage({namespace: "batch"}); + + await chrome.storage.local.set({ + "batch:a": 1, + "batch:b": 2, + "other:a": 99, + }); + + const getSpy = chrome.storage.local.get as jest.Mock; + getSpy.mockClear(); + + const result = await isolatedStorage.get(["a", "b", "missing"] as const); + + expect(result).toEqual({a: 1, b: 2}); + expect(getSpy).toHaveBeenCalledTimes(1); + expect(getSpy).toHaveBeenCalledWith(["batch:a", "batch:b", "batch:missing"], expect.any(Function)); + }); + + test("batch maps preserve a storage key named __proto__ without prototype mutation", async () => { + const isolatedStorage = new Storage(); + + await isolatedStorage.set({["__proto__"]: "stored", safe: "ok"}); + + const values = await isolatedStorage.get(["__proto__", "safe"] as const); + expect(Object.getPrototypeOf(values)).toBe(Object.prototype); + expect(Object.getOwnPropertyDescriptor(values, "__proto__")?.value).toBe("stored"); + expect(values.safe).toBe("ok"); + + const updated = await isolatedStorage.update(["__proto__", "safe"] as const, prev => ({ + ["__proto__"]: `${prev.__proto__}:updated`, + })); + + expect(Object.getPrototypeOf(updated)).toBe(Object.prototype); + expect(Object.getOwnPropertyDescriptor(updated, "__proto__")?.value).toBe("stored:updated"); + expect(updated.safe).toBe("ok"); + + const callback = jest.fn(); + const unsubscribe = isolatedStorage.subscribe(callback); + global.simulateStorageChanges({ + storage: isolatedStorage, + changes: {["__proto__"]: {oldValue: "stored", newValue: "stored:updated"}}, + }); + + await flushMacrotask(); + + expect(callback).toHaveBeenCalledTimes(1); + const changes = callback.mock.calls[0]?.[0]; + expect(Object.getPrototypeOf(changes)).toBe(Object.prototype); + expect(Object.getOwnPropertyDescriptor(changes, "__proto__")?.value).toEqual({ + oldValue: "stored", + newValue: "stored:updated", + }); + unsubscribe(); + }); + + test("batch set writes namespaced values in one native call without Web Locks", async () => { + Object.defineProperty(globalThis.navigator, "locks", { + value: undefined, + writable: true, + enumerable: true, + configurable: true, + }); + + const isolatedStorage = new Storage({namespace: "batch"}); + const setSpy = chrome.storage.local.set as jest.Mock; + setSpy.mockClear(); + + await isolatedStorage.set({a: 1, b: 2, c: "ready"}); + + expect(setSpy).toHaveBeenCalledTimes(1); + expect(setSpy).toHaveBeenCalledWith( + {"batch:a": 1, "batch:b": 2, "batch:c": "ready"}, + expect.any(Function) + ); + expect(await global.storageLocalGet(["a", "b", "c"], isolatedStorage)).toEqual({ + "batch:a": 1, + "batch:b": 2, + "batch:c": "ready", + }); + }); + + test("set overload rejects a two-argument undefined value before a native write", async () => { + const isolatedStorage = new Storage({namespace: "batch"}); + const setSpy = chrome.storage.local.set as jest.Mock; + setSpy.mockClear(); + + await expect(isolatedStorage.set("a", undefined as never)).rejects.toThrow(TypeError); + + expect(setSpy).not.toHaveBeenCalled(); + }); + + test("batch set rejects undefined values before a native write", async () => { + const isolatedStorage = new Storage({namespace: "batch"}); + const setSpy = chrome.storage.local.set as jest.Mock; + setSpy.mockClear(); + + await expect(isolatedStorage.set({a: undefined})).rejects.toThrow(TypeError); + + expect(setSpy).not.toHaveBeenCalled(); + }); + + test.each(["theme", null, [], new Date(), new Map(), () => undefined, new (class Value {})()])( + "batch set rejects a non-plain object container %#", + async values => { + const isolatedStorage = new Storage(); + const setSpy = chrome.storage.local.set as jest.Mock; + setSpy.mockClear(); + + await expect((isolatedStorage.set as (value: unknown) => Promise)(values)).rejects.toThrow( + TypeError + ); + expect(setSpy).not.toHaveBeenCalled(); + } + ); + + test("batch set accepts a null-prototype object", async () => { + const isolatedStorage = new Storage(); + const values = Object.create(null) as Partial; + values.a = 1; + + await isolatedStorage.set(values); + + await expect(isolatedStorage.get("a")).resolves.toBe(1); + }); + + test("batch set snapshots getter values once before native I/O", async () => { + const isolatedStorage = new Storage(); + const values: Partial = {}; + let reads = 0; + + Object.defineProperty(values, "a", { + enumerable: true, + get: () => { + reads += 1; + return reads === 1 ? 1 : undefined; + }, + }); + + await isolatedStorage.set(values); + + expect(reads).toBe(1); + await expect(isolatedStorage.get("a")).resolves.toBe(1); + }); + + test("missing prototype-like keys stay absent and batch updater snapshots do not inherit them", async () => { + const isolatedStorage = new Storage(); + const compare = jest.fn(() => false); + const compareMap = Object.create(null) as NonNullable< + StorageBatchUpdateOptions["compare"] + >; + compareMap.toString = compare; + + await expect(isolatedStorage.get("toString")).resolves.toBeUndefined(); + + const values = await isolatedStorage.get(["constructor", "toString", "valueOf"] as const); + expect(Object.keys(values)).toEqual([]); + + await isolatedStorage.update( + ["constructor", "toString", "valueOf"] as const, + prev => { + expect(Object.getPrototypeOf(prev)).toBeNull(); + expect(prev.constructor).toBeUndefined(); + expect(prev.toString).toBeUndefined(); + expect(prev.valueOf).toBeUndefined(); + + const patch = Object.create(null) as Partial; + patch.toString = "stored"; + + return patch; + }, + {compare: compareMap} + ); + + expect(compare).toHaveBeenCalledWith(undefined, "stored"); + await expect(isolatedStorage.get("toString")).resolves.toBe("stored"); + }); + + test("batch update writes only changed values using one snapshot and one native set", async () => { + const isolatedStorage = new Storage({namespace: "batch"}); + await isolatedStorage.set({a: 1, b: 2}); + + const getSpy = chrome.storage.local.get as jest.Mock; + const setSpy = chrome.storage.local.set as jest.Mock; + const removeSpy = chrome.storage.local.remove as jest.Mock; + getSpy.mockClear(); + setSpy.mockClear(); + removeSpy.mockClear(); + + const updater = jest.fn((prev: Partial>) => ({ + a: (prev.a ?? 0) + 1, + b: 2, + c: "created", + })); + + const result = await isolatedStorage.update(["a", "b", "c"] as const, updater); + + expect(updater).toHaveBeenCalledTimes(1); + expect(updater).toHaveBeenCalledWith({a: 1, b: 2}); + expect(result).toEqual({a: 2, b: 2, c: "created"}); + expect(getSpy).toHaveBeenCalledTimes(1); + expect(getSpy).toHaveBeenCalledWith(["batch:a", "batch:b", "batch:c"], expect.any(Function)); + expect(setSpy).toHaveBeenCalledTimes(1); + expect(setSpy).toHaveBeenCalledWith({"batch:a": 2, "batch:c": "created"}, expect.any(Function)); + expect(removeSpy).not.toHaveBeenCalled(); + }); + + test("batch update applies mixed writes before removals and returns the final snapshot", async () => { + const isolatedStorage = new Storage({namespace: "batch"}); + await isolatedStorage.set({a: 1, b: 2}); + + const setSpy = chrome.storage.local.set as jest.Mock; + const removeSpy = chrome.storage.local.remove as jest.Mock; + setSpy.mockClear(); + removeSpy.mockClear(); + + const result = await isolatedStorage.update(["a", "b"] as const, () => ({a: undefined, b: 3})); + + expect(result).toEqual({b: 3}); + expect(setSpy).toHaveBeenCalledTimes(1); + expect(setSpy).toHaveBeenCalledWith({"batch:b": 3}, expect.any(Function)); + expect(removeSpy).toHaveBeenCalledTimes(1); + expect(removeSpy).toHaveBeenCalledWith(["batch:a"], expect.any(Function)); + expect(setSpy.mock.invocationCallOrder[0]).toBeLessThan(removeSpy.mock.invocationCallOrder[0]); + }); + + test("batch update reports a partial commit when remove fails after set", async () => { + class FailingRemoveStorage extends Storage { + protected override async removeUnlocked(): Promise { + throw new Error("remove failed"); + } + } + + const isolatedStorage = new FailingRemoveStorage(); + await isolatedStorage.set({a: 1, b: 2}); + + await expect( + isolatedStorage.update(["a", "b"] as const, () => ({a: undefined, b: 3})) + ).rejects.toMatchObject({ + name: "StoragePartialUpdateError", + appliedSetKeys: ["b"], + attemptedRemoveKeys: ["a"], + cause: expect.objectContaining({message: "remove failed"}), + } satisfies Partial>); + + await expect(isolatedStorage.get(["a", "b"] as const)).resolves.toEqual({a: 1, b: 3}); + }); + + test("batch comparer maps ignore inherited prototype functions", async () => { + interface NamedKeys { + constructor?: string; + toString?: string; + valueOf?: string; + } + + const isolatedStorage = new Storage(); + await isolatedStorage.set({constructor: "old", toString: "old", valueOf: "old"}); + + await isolatedStorage.update( + ["constructor", "toString", "valueOf"] as const, + () => ({constructor: "new", toString: "new", valueOf: "new"}), + {compare: {}} + ); + + await expect(isolatedStorage.getAll()).resolves.toEqual({ + constructor: "new", + toString: "new", + valueOf: "new", + }); + }); + + test("batch update rejects patch keys outside the requested set without writing", async () => { + const isolatedStorage = new Storage(); + await isolatedStorage.set({a: 1}); + + const setSpy = chrome.storage.local.set as jest.Mock; + const removeSpy = chrome.storage.local.remove as jest.Mock; + setSpy.mockClear(); + removeSpy.mockClear(); + + await expect( + isolatedStorage.update( + ["a"] as const, + () => ({b: 2}) as unknown as Partial> + ) + ).rejects.toThrow(); + + expect(setSpy).not.toHaveBeenCalled(); + expect(removeSpy).not.toHaveBeenCalled(); + expect(await isolatedStorage.get(["a", "b"] as const)).toEqual({a: 1}); + }); + + test.each([new Date(), new Map(), new (class Patch {})()])( + "batch update rejects a non-plain patch %# without writing", + async patch => { + const isolatedStorage = new Storage(); + const setSpy = chrome.storage.local.set as jest.Mock; + const removeSpy = chrome.storage.local.remove as jest.Mock; + setSpy.mockClear(); + removeSpy.mockClear(); + + await expect( + isolatedStorage.update(["a"] as const, () => patch as Partial>) + ).rejects.toThrow(TypeError); + + expect(setSpy).not.toHaveBeenCalled(); + expect(removeSpy).not.toHaveBeenCalled(); + } + ); + + test("batch update does not write when the updater throws", async () => { + const isolatedStorage = new Storage(); + await isolatedStorage.set({a: 1, b: 2}); + + const setSpy = chrome.storage.local.set as jest.Mock; + const removeSpy = chrome.storage.local.remove as jest.Mock; + setSpy.mockClear(); + removeSpy.mockClear(); + + await expect( + isolatedStorage.update(["a", "b"] as const, async () => { + throw new Error("batch failed"); + }) + ).rejects.toThrow("batch failed"); + + expect(setSpy).not.toHaveBeenCalled(); + expect(removeSpy).not.toHaveBeenCalled(); + expect(await isolatedStorage.get(["a", "b"] as const)).toEqual({a: 1, b: 2}); + }); + + test("batch update skips native writes for an empty or equal patch", async () => { + const isolatedStorage = new Storage(); + await isolatedStorage.set({a: 1, b: 2}); + + const setSpy = chrome.storage.local.set as jest.Mock; + const removeSpy = chrome.storage.local.remove as jest.Mock; + setSpy.mockClear(); + removeSpy.mockClear(); + + await expect(isolatedStorage.update(["a", "b"] as const, () => ({}))).resolves.toEqual({a: 1, b: 2}); + await expect(isolatedStorage.update(["a", "b"] as const, () => ({a: 1, b: 2}))).resolves.toEqual({ + a: 1, + b: 2, + }); + + expect(setSpy).not.toHaveBeenCalled(); + expect(removeSpy).not.toHaveBeenCalled(); + }); + + test("batch update applies per-key comparers", async () => { + const isolatedStorage = new Storage(); + await isolatedStorage.set({a: 1, b: 2}); + + const compareA = jest.fn(() => true); + const compareB = jest.fn(() => false); + const setSpy = chrome.storage.local.set as jest.Mock; + setSpy.mockClear(); + + const result = await isolatedStorage.update( + ["a", "b"] as const, + () => ({a: 2, b: 2}), + {compare: {a: compareA, b: compareB}} + ); + + expect(compareA).toHaveBeenCalledWith(1, 2); + expect(compareB).toHaveBeenCalledWith(2, 2); + expect(result).toEqual({a: 1, b: 2}); + expect(setSpy).toHaveBeenCalledTimes(1); + expect(setSpy).toHaveBeenCalledWith({b: 2}, expect.any(Function)); + }); + + test("batch update deduplicates and sorts lock keys while forwarding lock options", async () => { + const requests: Array<{name: string; options: any}> = []; + const locker: StorageLocker = { + async request(name, task, options) { + requests.push({name, options}); + return await task(); + }, + }; + const isolatedStorage = new Storage({namespace: "batch", locker}); + const controller = new AbortController(); + + await isolatedStorage.update(["b", "a", "b"] as const, () => ({a: 1, b: 2}), { + signal: controller.signal, + timeout: 25, + }); + + expect(requests).toEqual([ + {name: "batch:a", options: {signal: controller.signal, timeout: 25}}, + {name: "batch:b", options: {signal: controller.signal, timeout: 25}}, + ]); + }); + + test("batch update serializes batches with overlapping keys regardless of key order", async () => { + const isolatedStorage = new Storage(); + await isolatedStorage.set({a: 0, b: 0}); + + await Promise.all([ + isolatedStorage.update(["a", "b"] as const, async prev => { + await new Promise(resolve => setTimeout(resolve, 10)); + return {a: (prev.a ?? 0) + 1, b: (prev.b ?? 0) + 1}; + }), + isolatedStorage.update(["b", "a"] as const, async prev => { + await new Promise(resolve => setTimeout(resolve, 10)); + return {a: (prev.a ?? 0) + 1, b: (prev.b ?? 0) + 1}; + }), + ]); + + expect(await isolatedStorage.get(["a", "b"] as const)).toEqual({a: 2, b: 2}); + }); + + test("batch update coordinates with a single-key update on an overlapping key", async () => { + const isolatedStorage = new Storage(); + await isolatedStorage.set({a: 0, b: 0}); + + await Promise.all([ + isolatedStorage.update(["a", "b"] as const, async prev => { + await new Promise(resolve => setTimeout(resolve, 10)); + return {a: (prev.a ?? 0) + 1, b: (prev.b ?? 0) + 1}; + }), + isolatedStorage.update("b", async prev => { + await new Promise(resolve => setTimeout(resolve, 5)); + return (prev ?? 0) + 1; + }), + ]); + + expect(await isolatedStorage.get(["a", "b"] as const)).toEqual({a: 1, b: 2}); + }); + + test("batch update aborts before reading or writing when a later lock fails", async () => { + const abortError = new Error("The lock request was aborted."); + abortError.name = "AbortError"; + + const locker: StorageLocker = { + async request(name, task) { + if (name === "b") { + throw abortError; + } + + return await task(); + }, + }; + const isolatedStorage = new Storage({locker}); + await isolatedStorage.set({a: 1, b: 2}); + + const getSpy = chrome.storage.local.get as jest.Mock; + const setSpy = chrome.storage.local.set as jest.Mock; + const removeSpy = chrome.storage.local.remove as jest.Mock; + getSpy.mockClear(); + setSpy.mockClear(); + removeSpy.mockClear(); + + await expect(isolatedStorage.update(["a", "b"] as const, () => ({a: 2, b: 3}))).rejects.toMatchObject({ + name: "AbortError", + }); + + expect(getSpy).not.toHaveBeenCalled(); + expect(setSpy).not.toHaveBeenCalled(); + expect(removeSpy).not.toHaveBeenCalled(); + }); + + test("empty batch operations do not call native storage", async () => { + const isolatedStorage = new Storage(); + const updater = jest.fn(() => ({})); + const getSpy = chrome.storage.local.get as jest.Mock; + const setSpy = chrome.storage.local.set as jest.Mock; + const removeSpy = chrome.storage.local.remove as jest.Mock; + getSpy.mockClear(); + setSpy.mockClear(); + removeSpy.mockClear(); + + await expect(isolatedStorage.get([])).resolves.toEqual({}); + await expect(isolatedStorage.set({})).resolves.toBeUndefined(); + await expect(isolatedStorage.update([], updater)).resolves.toEqual({}); + + expect(updater).not.toHaveBeenCalled(); + expect(getSpy).not.toHaveBeenCalled(); + expect(setSpy).not.toHaveBeenCalled(); + expect(removeSpy).not.toHaveBeenCalled(); + }); +}); + +describe("namespace separator validation", () => { + test("rejects invalid namespaces and factory bucket keys immediately", () => { + expect(() => new Storage({namespace: "invalid:namespace"})).toThrow( + 'Storage namespace "invalid:namespace" must not contain the namespace separator ":".' + ); + expect(() => Storage.Local({key: "invalid:bucket"})).toThrow( + 'Storage key "invalid:bucket" must not contain the namespace separator ":".' + ); + }); + + test("rejects separator keys in every single-key operation and keyed watch before native work", async () => { + const isolatedStorage = new Storage(); + const updater = jest.fn(() => "updated"); + const getSpy = chrome.storage.local.get as jest.Mock; + const setSpy = chrome.storage.local.set as jest.Mock; + const removeSpy = chrome.storage.local.remove as jest.Mock; + const addListenerSpy = chrome.storage.onChanged.addListener as jest.Mock; + getSpy.mockClear(); + setSpy.mockClear(); + removeSpy.mockClear(); + addListenerSpy.mockClear(); + + await expect(isolatedStorage.get("bad:key")).rejects.toThrow(TypeError); + await expect(isolatedStorage.set("bad:key", "value")).rejects.toThrow(TypeError); + await expect(isolatedStorage.update("bad:key", updater)).rejects.toThrow(TypeError); + await expect(isolatedStorage.remove("bad:key")).rejects.toThrow(TypeError); + expect(() => isolatedStorage.watch({"bad:key": jest.fn()})).toThrow(TypeError); + + expect(updater).not.toHaveBeenCalled(); + expect(getSpy).not.toHaveBeenCalled(); + expect(setSpy).not.toHaveBeenCalled(); + expect(removeSpy).not.toHaveBeenCalled(); + expect(addListenerSpy).not.toHaveBeenCalled(); + }); + + test("rejects an entire invalid batch before updater, locks, or native I/O", async () => { + const isolatedStorage = new Storage(); + const updater = jest.fn(() => ({good: "updated"})); + const getSpy = chrome.storage.local.get as jest.Mock; + const setSpy = chrome.storage.local.set as jest.Mock; + const removeSpy = chrome.storage.local.remove as jest.Mock; + getSpy.mockClear(); + setSpy.mockClear(); + removeSpy.mockClear(); + + await expect(isolatedStorage.get(["good", "bad:key"] as const)).rejects.toThrow(TypeError); + await expect(isolatedStorage.set({good: "value", "bad:key": "invalid"})).rejects.toThrow(TypeError); + await expect(isolatedStorage.update(["good", "bad:key"] as const, updater)).rejects.toThrow(TypeError); + await expect(isolatedStorage.remove(["good", "bad:key"])).rejects.toThrow(TypeError); + + expect(updater).not.toHaveBeenCalled(); + expect(getSpy).not.toHaveBeenCalled(); + expect(setSpy).not.toHaveBeenCalled(); + expect(removeSpy).not.toHaveBeenCalled(); + }); +}); + test("getAll method - returns all values from current namespace", async () => { await storage.set("a", 1); await storage.set("b", 2); @@ -268,13 +835,37 @@ test("clear method - removes all keys from current namespace", async () => { expect(resultWithNamespace).toEqual({c: 3, d: 4}); }); +describe("strict physical key codec", () => { + test("getAll and clear only accept the exact shape for their namespace", async () => { + const plain = new Storage<{plain?: number}>(); + const namespaced = new Storage<{theme?: string}>({namespace: "auth"}); + + await chrome.storage.local.set({ + plain: 1, + "foreign:value": "keep", + "auth:theme": "dark", + "auth:theme:extra": "keep", + }); + + await expect(plain.getAll()).resolves.toEqual({plain: 1}); + await expect(namespaced.getAll()).resolves.toEqual({theme: "dark"}); + + await plain.clear(); + await namespaced.clear(); + + await expect(getAllFromArea("local")).resolves.toEqual({ + "foreign:value": "keep", + "auth:theme:extra": "keep", + }); + }); +}); + describe("set/get methods with different type of value", () => { test.each([ ["string", "hello"], ["number", 42], ["boolean", true], ["null", null], - ["undefined", undefined], ["object", {a: 1, b: true}], ["array", [1, 2, 3]], ])("set/get with %s", async (_, value) => { @@ -285,6 +876,20 @@ describe("set/get methods with different type of value", () => { }); describe("remove method", () => { + test("deletes multiple keys in one native remove call", async () => { + const isolatedStorage = new Storage({namespace: "batch"}); + await isolatedStorage.set({a: 1, b: 2, c: "keep"}); + + const removeSpy = chrome.storage.local.remove as jest.Mock; + removeSpy.mockClear(); + + await isolatedStorage.remove(["a", "b"]); + + expect(removeSpy).toHaveBeenCalledTimes(1); + expect(removeSpy).toHaveBeenCalledWith(["batch:a", "batch:b"], expect.any(Function)); + expect(await isolatedStorage.getAll()).toEqual({c: "keep"}); + }); + test("deletes the key without namespace", async () => { await storage.set("theme", "dark"); await storage.remove("theme"); @@ -300,8 +905,225 @@ describe("remove method", () => { }); }); -describe("watch method", () => { - test("calls specific key callback on change", () => { +describe("watch and subscribe methods", () => { + test("subscribe receives one logical change map for one native multi-key event", async () => { + const isolatedStorage = new Storage({namespace: "batch"}); + const batchCallback = jest.fn(); + const legacyCallback = jest.fn(); + const unsubscribeBatch = isolatedStorage.subscribe(batchCallback); + const unsubscribeLegacy = isolatedStorage.watch(legacyCallback); + + global.simulateStorageChanges({ + storage: isolatedStorage, + changes: { + a: {oldValue: 1, newValue: 2}, + c: {oldValue: undefined, newValue: "created"}, + }, + }); + + await flushMacrotask(); + + expect(batchCallback).toHaveBeenCalledTimes(1); + expect(batchCallback).toHaveBeenCalledWith({ + a: {oldValue: 1, newValue: 2}, + c: {oldValue: undefined, newValue: "created"}, + }); + expect(legacyCallback).toHaveBeenCalledTimes(2); + expect(legacyCallback).toHaveBeenCalledWith(2, 1, "a"); + expect(legacyCallback).toHaveBeenCalledWith("created", undefined, "c"); + + unsubscribeBatch(); + + global.simulateStorageChanges({ + storage: isolatedStorage, + changes: {a: {oldValue: 2, newValue: 3}}, + }); + + await flushMacrotask(); + + expect(batchCallback).toHaveBeenCalledTimes(1); + unsubscribeLegacy(); + }); + + test("subscribe ignores events from another namespace or storage area", async () => { + const isolatedStorage = new Storage({namespace: "batch"}); + const otherNamespace = new Storage({namespace: "other"}); + const callback = jest.fn(); + const unsubscribe = isolatedStorage.subscribe(callback); + + global.simulateStorageChanges({ + storage: otherNamespace, + changes: {a: {oldValue: 1, newValue: 2}}, + }); + global.simulateStorageChanges({ + storage: isolatedStorage, + changes: {a: {oldValue: 1, newValue: 2}}, + areaName: "sync", + }); + + await flushMacrotask(); + + expect(callback).not.toHaveBeenCalled(); + unsubscribe(); + }); + + test("subscribe filters deep-equal entries and skips empty change maps", async () => { + const isolatedStorage = new Storage(); + const callback = jest.fn(); + const unsubscribe = isolatedStorage.subscribe(callback); + + global.simulateStorageChanges({ + storage: isolatedStorage, + changes: { + a: {oldValue: 1, newValue: 1}, + b: {oldValue: 2, newValue: 3}, + }, + }); + + await flushMacrotask(); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith({b: {oldValue: 2, newValue: 3}}); + + global.simulateStorageChanges({ + storage: isolatedStorage, + changes: {a: {oldValue: 1, newValue: 1}}, + }); + + await flushMacrotask(); + + expect(callback).toHaveBeenCalledTimes(1); + unsubscribe(); + }); + + test("unsubscribe prevents delivery from an event already queued for formatting", async () => { + const callback = jest.fn(); + const unsubscribe = storage.subscribe(callback); + + global.simulateStorageChange({ + storage, + key: "theme", + oldValue: "light", + newValue: "dark", + }); + unsubscribe(); + + await flushMacrotask(); + + expect(callback).not.toHaveBeenCalled(); + }); + + test("an unresolved subscriber promise does not block later events", async () => { + const pending = new Promise(() => undefined); + const callback = jest.fn().mockReturnValueOnce(pending).mockReturnValue(undefined); + const unsubscribe = storage.subscribe(callback); + + global.simulateStorageChange({storage, key: "theme", oldValue: "light", newValue: "dark"}); + global.simulateStorageChange({storage, key: "volume", oldValue: 10, newValue: 20}); + + await flushMacrotask(); + + expect(callback).toHaveBeenCalledTimes(2); + unsubscribe(); + }); + + test("watch callback errors are uncaught without stopping sibling handlers or future events", async () => { + const errors = captureUnhandledErrors(); + const failure = new Error("watch failed"); + const themeCallback = jest.fn(() => { + throw failure; + }); + const volumeCallback = jest.fn(); + const unsubscribe = storage.watch({theme: themeCallback, volume: volumeCallback}); + + try { + global.simulateStorageChanges({ + storage, + changes: { + theme: {oldValue: "light", newValue: "dark"}, + volume: {oldValue: 10, newValue: 20}, + }, + }); + + await flushMacrotask(); + + expect(themeCallback).toHaveBeenCalledTimes(1); + expect(volumeCallback).toHaveBeenCalledTimes(1); + expect(errors.pending).toBe(1); + expect(() => errors.runNext()).toThrow(failure); + + global.simulateStorageChange({storage, key: "volume", oldValue: 20, newValue: 30}); + await flushMacrotask(); + + expect(volumeCallback).toHaveBeenCalledTimes(2); + } finally { + unsubscribe(); + errors.restore(); + } + }); + + test("watch only resolves keyed callbacks from own properties", async () => { + const prototypeCallback = jest.fn(); + const ownCallback = jest.fn(); + const handlers = Object.create({theme: prototypeCallback}) as Record; + handlers.volume = ownCallback; + const unsubscribe = storage.watch(handlers); + + global.simulateStorageChanges({ + storage, + changes: { + theme: {oldValue: "light", newValue: "dark"}, + volume: {oldValue: 10, newValue: 20}, + }, + }); + + await flushMacrotask(); + + expect(prototypeCallback).not.toHaveBeenCalled(); + expect(ownCallback).toHaveBeenCalledWith(20, 10); + unsubscribe(); + }); + + test("a throwing keyed watcher getter does not block sibling handlers or dispose the watch", async () => { + const errors = captureUnhandledErrors(); + const failure = new Error("watch getter failed"); + const volumeCallback = jest.fn(); + const handlers: Record = {volume: volumeCallback}; + + Object.defineProperty(handlers, "theme", { + enumerable: true, + get: () => { + throw failure; + }, + }); + + const unsubscribe = storage.watch(handlers); + + try { + global.simulateStorageChanges({ + storage, + changes: { + theme: {oldValue: "light", newValue: "dark"}, + volume: {oldValue: 10, newValue: 20}, + }, + }); + + await flushMacrotask(); + + expect(volumeCallback).toHaveBeenCalledTimes(1); + expect(errors.pending).toBe(1); + expect(() => errors.runNext()).toThrow(failure); + + global.simulateStorageChange({storage, key: "volume", oldValue: 20, newValue: 30}); + await flushMacrotask(); + expect(volumeCallback).toHaveBeenCalledTimes(2); + } finally { + unsubscribe(); + errors.restore(); + } + }); + + test("calls specific key callback on change", async () => { const keyCallback = jest.fn(); storage.watch({theme: keyCallback}); @@ -312,10 +1134,12 @@ describe("watch method", () => { newValue: "dark", }); + await flushMacrotask(); + expect(keyCallback).toHaveBeenCalledWith("dark", "light"); }); - test("does not call key callback for unrelated key", () => { + test("does not call key callback for unrelated key", async () => { const keyCallback = jest.fn(); storage.watch({theme: keyCallback}); @@ -326,10 +1150,12 @@ describe("watch method", () => { newValue: 80, }); + await flushMacrotask(); + expect(keyCallback).not.toHaveBeenCalled(); }); - test("calls global callback on any change", () => { + test("calls global callback on any change", async () => { const globalCallback = jest.fn(); storage.watch(globalCallback); @@ -346,11 +1172,13 @@ describe("watch method", () => { newValue: 80, }); + await flushMacrotask(); + expect(globalCallback).toHaveBeenCalledWith(80, 50, "volume"); expect(globalCallback).toHaveBeenCalledWith("dark", "light", "theme"); }); - test("calls both key and global callbacks", () => { + test("calls both key and global callbacks", async () => { const keyCallback = jest.fn(); const globalCallback = jest.fn(); storage.watch({theme: keyCallback}); @@ -369,6 +1197,8 @@ describe("watch method", () => { newValue: 80, }); + await flushMacrotask(); + expect(keyCallback).toHaveBeenCalledWith("dark", "light"); expect(globalCallback).toHaveBeenCalledWith(80, 50, "volume"); expect(globalCallback).toHaveBeenCalledWith("dark", "light", "theme"); @@ -468,7 +1298,7 @@ describe("static factory methods", () => { if (!hasArea("session")) { return; } - const s = Storage.Session(); + const s = Storage.Session<{s?: number}>(); expect(s).toBeInstanceOf(Storage); await (s as Storage).set("s" as any, 5 as any); diff --git a/src/providers/Storage.ts b/src/providers/Storage.ts index 1305958..c4f5101 100644 --- a/src/providers/Storage.ts +++ b/src/providers/Storage.ts @@ -1,7 +1,6 @@ +import {STORAGE_KEY_SEPARATOR} from "../constants"; import AbstractStorage, {type StorageOptions} from "./AbstractStorage"; -import type {StorageLockOptions, StorageState, StorageWatchOptions} from "../types"; - -type StorageChange = chrome.storage.StorageChange; +import type {StorageLockOptions, StorageState} from "../types"; export default class Storage extends AbstractStorage { constructor(options: StorageOptions = {}) { @@ -14,29 +13,19 @@ export default class Storage extends AbstractStorage await this.remove(Object.keys(allValues), options); } - protected isKeyValid(key: string): boolean { - if (!super.isKeyValid(key)) return false; - - const parts = key.split(this.separator); - - return parts.length === 1 || (parts.length === 2 && parts[0] === this.namespace); - } + protected getFullKey(key: keyof T): string { + const logicalKey = this.toLogicalKey(key); - protected async handleChange

( - key: string, - changes: StorageChange, - options: StorageWatchOptions

- ): Promise { - await this.triggerChange(key, changes, options); + return this.namespace ? `${this.namespace}${STORAGE_KEY_SEPARATOR}${logicalKey}` : logicalKey; } - protected getFullKey(key: keyof T): string { - return this.namespace ? `${this.namespace}${this.separator}${key.toString()}` : key.toString(); - } + protected decodeFullKey(fullKey: string): keyof T | null { + const parts = fullKey.split(STORAGE_KEY_SEPARATOR); - protected getNamespaceOfKey(key: string): string | undefined { - const fullKeyParts = key.split(this.separator); + if (this.namespace === undefined) { + return parts.length === 1 ? (fullKey as keyof T) : null; + } - return fullKeyParts.length === 2 ? fullKeyParts[0] : undefined; + return parts.length === 2 && parts[0] === this.namespace ? (parts[1] as keyof T) : null; } } diff --git a/src/providers/index.ts b/src/providers/index.ts index ed01da0..4ca7b5f 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -1,3 +1,5 @@ export {default as MonoStorage} from "./MonoStorage"; export {default as SecureStorage} from "./SecureStorage"; export {default as Storage} from "./Storage"; +export type {StorageOptions} from "./AbstractStorage"; +export type {SecureStorageOptions} from "./SecureStorage"; diff --git a/src/types.ts b/src/types.ts index 842b48d..803d8b1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,9 @@ export type StorageState = Record; +export type StorageSetValue = T & (NonNullable | null); + +export type StorageListenerResult = void | Promise; + export interface StorageLockOptions { /** * Cancels lock acquisition while the request is still queued. @@ -32,21 +36,43 @@ export interface StorageUpdateOptions extends StorageLockOptions { compare?: StorageUpdateComparer; } +export type StorageBatchUpdater = ( + prev: Partial> +) => Partial> | Promise>>; + +export interface StorageBatchUpdateOptions extends StorageLockOptions { + /** + * Per-key equality checks. Return `true` to skip the physical write for that key. + */ + compare?: Partial<{[P in K]: StorageUpdateComparer}>; +} + export type StorageWatchCallback = ( newValue: T[K] | undefined, oldValue: T[K] | undefined, key: K -) => void; +) => StorageListenerResult; export type StorageWatchKeyCallback = { - [K in keyof T]?: (newValue: T[K] | undefined, oldValue: T[K] | undefined) => void; + [K in keyof T]?: (newValue: T[K] | undefined, oldValue: T[K] | undefined) => StorageListenerResult; }; export type StorageWatchOptions = StorageWatchKeyCallback | StorageWatchCallback; +export type StorageChanges = Partial<{ + [K in keyof T]: { + newValue: T[K] | undefined; + oldValue: T[K] | undefined; + }; +}>; + +export type StorageSubscriber = (changes: StorageChanges) => StorageListenerResult; + // prettier-ignore export interface StorageProvider { - set(key: K, value: T[K]): Promise; + set(key: K, value: StorageSetValue): Promise; + + set(values: Partial): Promise; update( key: K, @@ -54,8 +80,16 @@ export interface StorageProvider { options?: StorageUpdateOptions ): Promise; + update( + keys: readonly K[], + updater: StorageBatchUpdater, + options?: StorageBatchUpdateOptions + ): Promise>>; + get(key: K): Promise; + get(keys: readonly K[]): Promise>>; + getAll(): Promise>; remove(keys: K | K[], options?: StorageLockOptions): Promise; @@ -63,4 +97,6 @@ export interface StorageProvider { clear(options?: StorageLockOptions): Promise; watch(options: StorageWatchOptions): () => void; + + subscribe(callback: StorageSubscriber): () => void; } diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..e8932f2 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,113 @@ +import {STORAGE_KEY_SEPARATOR} from "./constants"; + +export const hasOwn = (value: object, key: PropertyKey): boolean => + Object.getOwnPropertyDescriptor(value, key) !== undefined; + +export const setRecordValue = (target: object, key: PropertyKey, value: unknown): void => { + Object.defineProperty(target, key, { + configurable: true, + enumerable: true, + value, + writable: true, + }); +}; + +export const createRecord = (): T => ({}) as T; + +export const copyRecordWithoutPrototype = (value: T): T => { + const copy = Object.create(null) as T; + + for (const key of Object.keys(value)) { + setRecordValue(copy, key, (value as Record)[key]); + } + + return copy; +}; + +export const copyRecord = (value: T): T => { + const copy = createRecord(); + + for (const key of Object.keys(value)) { + setRecordValue(copy, key, (value as Record)[key]); + } + + return copy; +}; + +export const isPlainObject = (value: unknown): value is Record => { + if (value === null || typeof value !== "object") { + return false; + } + + const prototype = Object.getPrototypeOf(value); + + return prototype === Object.prototype || prototype === null; +}; + +const assertStorageIdentifier = (type: "key" | "namespace", value: string | undefined): void => { + if (value?.includes(STORAGE_KEY_SEPARATOR)) { + throw new TypeError( + `Storage ${type} "${value}" must not contain the namespace separator "${STORAGE_KEY_SEPARATOR}".` + ); + } +}; + +export const assertStorageKey = (key: PropertyKey): void => { + assertStorageIdentifier("key", key.toString()); +}; + +export const assertStorageNamespace = (namespace: string | undefined): void => { + assertStorageIdentifier("namespace", namespace); +}; + +export const normalizeStorageNamespace = (namespace: string | undefined): string | undefined => { + const normalizedNamespace = namespace?.trim(); + + return normalizedNamespace || undefined; +}; + +export const assertStorageSetValue = (value: unknown): void => { + if (value === undefined) { + throw new TypeError("Storage set value must not be undefined. Use remove() or update() to delete a value."); + } +}; + +export const prepareStorageSetValues = (values: unknown): T => { + if (!isPlainObject(values)) { + throw new TypeError("Storage batch set values must be a plain object."); + } + + const preparedValues = createRecord(); + + for (const key of Object.keys(values)) { + const value = values[key]; + + if (value === undefined) { + throw new TypeError( + `Storage batch set value for key "${key}" must not be undefined. Use remove() or update() to delete a value.` + ); + } + + setRecordValue(preparedValues, key, value); + } + + return preparedValues; +}; + +export const scheduleUnhandledError = (error: unknown): void => { + globalThis.queueMicrotask(() => { + throw error; + }); +}; + +export const invokeCallback = (callback: () => void | Promise): void => { + try { + const result = callback(); + + if (result !== undefined) { + void Promise.resolve(result).catch(scheduleUnhandledError); + } + } catch (error) { + scheduleUnhandledError(error); + } +}; diff --git a/src/watch.test.ts b/src/watch.test.ts new file mode 100644 index 0000000..208427c --- /dev/null +++ b/src/watch.test.ts @@ -0,0 +1,161 @@ +import type { + StorageChanges, + StorageSubscriber, + StorageWatchKeyCallback, + StorageWatchOptions, +} from "./types"; +import {watchChanges} from "./watch"; +import {captureUnhandledErrors} from "../tests/helpers/async"; + +interface WatchState { + first?: string; + second?: number; +} + +const createSubscription = () => { + let emit: StorageSubscriber = () => undefined; + const unsubscribe = jest.fn(); + const subscribe = jest.fn((callback: StorageSubscriber) => { + emit = callback; + + return unsubscribe; + }); + + return { + emit: (changes: StorageChanges) => emit(changes), + subscribe, + unsubscribe, + }; +}; + +describe("watchChanges", () => { + test("projects each changed key onto a function watcher", () => { + const subscription = createSubscription(); + const watcher = jest.fn(); + const stop = watchChanges( + subscription.subscribe, + watcher as StorageWatchOptions + ); + + subscription.emit({ + first: {newValue: "after", oldValue: "before"}, + second: {newValue: 2, oldValue: 1}, + }); + + expect(subscription.subscribe).toHaveBeenCalledTimes(1); + expect(watcher).toHaveBeenNthCalledWith(1, "after", "before", "first"); + expect(watcher).toHaveBeenNthCalledWith(2, 2, 1, "second"); + + stop(); + }); + + test("calls only own object-watcher handlers", () => { + const subscription = createSubscription(); + const inheritedHandler = jest.fn(); + const ownHandler = jest.fn(); + const watcher = Object.create({first: inheritedHandler}) as StorageWatchKeyCallback; + + Object.defineProperty(watcher, "second", { + configurable: true, + enumerable: true, + value: ownHandler, + }); + + const stop = watchChanges(subscription.subscribe, watcher); + + subscription.emit({ + first: {newValue: "after", oldValue: "before"}, + second: {newValue: 2, oldValue: 1}, + }); + + expect(inheritedHandler).not.toHaveBeenCalled(); + expect(ownHandler).toHaveBeenCalledTimes(1); + expect(ownHandler).toHaveBeenCalledWith(2, 1); + + stop(); + }); + + test("stops fan-out immediately and unsubscribes only once", () => { + const subscription = createSubscription(); + let stop = (): void => undefined; + const firstHandler = jest.fn(() => stop()); + const secondHandler = jest.fn(); + + stop = watchChanges(subscription.subscribe, { + first: firstHandler, + second: secondHandler, + }); + + subscription.emit({ + first: {newValue: "after", oldValue: "before"}, + second: {newValue: 2, oldValue: 1}, + }); + + expect(firstHandler).toHaveBeenCalledTimes(1); + expect(secondHandler).not.toHaveBeenCalled(); + expect(subscription.unsubscribe).toHaveBeenCalledTimes(1); + + stop(); + + expect(subscription.unsubscribe).toHaveBeenCalledTimes(1); + }); + + test("reports a synchronous handler error without blocking later keys", () => { + const errors = captureUnhandledErrors(); + + try { + const subscription = createSubscription(); + const callbackError = new Error("sync watcher failure"); + const secondHandler = jest.fn(); + const stop = watchChanges(subscription.subscribe, { + first: () => { + throw callbackError; + }, + second: secondHandler, + }); + + subscription.emit({ + first: {newValue: "after", oldValue: "before"}, + second: {newValue: 2, oldValue: 1}, + }); + + expect(secondHandler).toHaveBeenCalledTimes(1); + expect(errors.pending).toBe(1); + expect(() => errors.runNext()).toThrow(callbackError); + + stop(); + } finally { + errors.restore(); + } + }); + + test("reports an asynchronous handler rejection without blocking later keys", async () => { + const errors = captureUnhandledErrors(); + + try { + const subscription = createSubscription(); + const callbackError = new Error("async watcher failure"); + const secondHandler = jest.fn(); + const stop = watchChanges(subscription.subscribe, { + first: () => Promise.reject(callbackError), + second: secondHandler, + }); + + subscription.emit({ + first: {newValue: "after", oldValue: "before"}, + second: {newValue: 2, oldValue: 1}, + }); + + expect(secondHandler).toHaveBeenCalledTimes(1); + + await Promise.resolve(); + + expect(errors.pending).toBe(1); + expect(() => errors.runNext()).toThrow(callbackError); + + stop(); + } finally { + errors.restore(); + } + }); +}); diff --git a/src/watch.ts b/src/watch.ts new file mode 100644 index 0000000..ee41f83 --- /dev/null +++ b/src/watch.ts @@ -0,0 +1,52 @@ +import {hasOwn, invokeCallback} from "./utils"; +import type {StorageState, StorageSubscriber, StorageWatchOptions} from "./types"; + +/** + * Projects a `subscribe()` change map onto the per-key `watch()` contract. + * + * A watcher error is routed through `invokeCallback`, so it never blocks the + * remaining keys of the same event. Disposal is checked before every key + * handler, which lets a handler unsubscribe mid fan-out. + */ +export const watchChanges = ( + subscribe: (callback: StorageSubscriber) => () => void, + watcher: StorageWatchOptions +): (() => void) => { + let disposed = false; + + const unsubscribe = subscribe(changes => { + for (const key of Object.keys(changes) as (keyof T)[]) { + if (disposed) { + break; + } + + const change = changes[key]; + + if (!change) { + continue; + } + + if (typeof watcher === "function") { + invokeCallback(() => watcher(change.newValue, change.oldValue, key)); + continue; + } + + invokeCallback(() => { + const callback = hasOwn(watcher, key) ? watcher[key] : undefined; + + if (typeof callback === "function") { + return callback(change.newValue, change.oldValue); + } + }); + } + }); + + return () => { + if (disposed) { + return; + } + + disposed = true; + unsubscribe(); + }; +}; diff --git a/tests/batch.types.ts b/tests/batch.types.ts new file mode 100644 index 0000000..8f72af2 --- /dev/null +++ b/tests/batch.types.ts @@ -0,0 +1,183 @@ +import type { + MonoStorage, + SecureStorage, + Storage, + StorageBatchUpdateOptions, + StorageBatchUpdater, + StorageChanges, + StorageProvider, + StorageSubscriber, +} from "../src"; +import {useStorage, type UseStorageProvider} from "../src/adapters/react"; + +interface TypedState { + count?: number; + theme?: "light" | "dark"; + enabled?: boolean; + nullable?: string | null; +} + +declare const storage: StorageProvider; + +type Equal = + (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 ? true : false; +type Expect = Value; + +async function verifyOverloadTypes() { + const count = await storage.get("count"); + type SingleGetResult = Expect>; + + const keys = ["count", "theme"] as const; + const values = await storage.get(keys); + type BatchGetResult = Expect>>>; + + values.count?.toFixed(); + values.theme?.toUpperCase(); + + await storage.set("enabled", true); + await storage.set("nullable", null); + // @ts-expect-error undefined deletion is only supported by update/remove + await storage.set("theme", undefined); + await storage.set({count: 1, theme: "dark"}); + + const nextTheme = await storage.update( + "theme", + async prev => prev ?? "light", + {compare: (prev, next) => prev === next} + ); + type SingleUpdateResult = Expect>; + + const updated = await storage.update( + ["count", "enabled"] as const, + async prev => { + type BatchUpdaterSnapshot = Expect< + Equal>> + >; + + return {count: (prev.count ?? 0) + 1, enabled: prev.enabled ?? true}; + }, + { + compare: { + count: (prev, next) => prev === next, + enabled: (prev, next) => prev === next, + }, + } + ); + type BatchUpdateResult = Expect>>>; + + updated.count?.toFixed(); + updated.enabled?.valueOf(); + + const subscriber: StorageSubscriber = changes => { + changes.count?.newValue?.toFixed(); + changes.theme?.oldValue?.toUpperCase(); + }; + const unsubscribe = storage.subscribe(changes => { + type SubscriberChanges = Expect>>; + + subscriber(changes); + }); + type UnsubscribeResult = Expect void>>; + unsubscribe(); + + const unwatch = storage.watch({ + count(next, prev) { + type WatchNext = Expect>; + type WatchPrev = Expect>; + }, + theme(next, prev) { + type WatchNext = Expect>; + type WatchPrev = Expect>; + }, + }); + type UnwatchResult = Expect void>>; + unwatch(); + + const batchUpdater: StorageBatchUpdater = prev => ({ + count: prev.count, + }); + const batchOptions: StorageBatchUpdateOptions = { + compare: { + count: (prev, next) => prev === next, + }, + }; + await storage.update(["count", "enabled"] as const, batchUpdater, batchOptions); + + // @ts-expect-error unknown single storage key + await storage.get("unknown"); + + // @ts-expect-error unknown batch storage key + await storage.get(["count", "unknown"] as const); + + // @ts-expect-error invalid value for the single-key overload + await storage.set("theme", "blue"); + + // @ts-expect-error invalid value for the object overload + await storage.set({theme: "blue"}); + + // @ts-expect-error unknown key in the object overload + await storage.set({unknown: true}); + + // @ts-expect-error batch updater patch value does not match the selected key + await storage.update(["count"] as const, () => ({count: "one"})); + + // @ts-expect-error batch updater patch contains a key outside the selected tuple + await storage.update(["count"] as const, () => ({theme: "dark"})); + + // @ts-expect-error removed API + storage.getMany(["count"] as const); + + // @ts-expect-error removed API + storage.setMany({count: 1}); + + // @ts-expect-error removed API + storage.updateMany(["count"] as const, () => ({count: 1})); + + // @ts-expect-error removed API + storage.watchMany(() => undefined); +} + +void verifyOverloadTypes; + +async function saveDefinedValue, Key extends keyof State>( + provider: StorageProvider, + key: Key, + value: State[Key] +) { + if (value !== undefined) { + await provider.set(key, value); + } +} + +void saveDefinedValue; + +declare const concreteStorage: Storage; +declare const concreteSecureStorage: SecureStorage; +declare const concreteMonoStorage: MonoStorage; + +async function verifyConcreteSetTypes() { + await concreteStorage.set("nullable", null); + await concreteSecureStorage.set("nullable", null); + await concreteMonoStorage.set("nullable", null); + + // @ts-expect-error undefined deletion is only supported by update/remove + await concreteStorage.set("theme", undefined); + // @ts-expect-error undefined deletion is only supported by update/remove + await concreteSecureStorage.set("theme", undefined); + // @ts-expect-error undefined deletion is only supported by update/remove + await concreteMonoStorage.set("theme", undefined); +} + +void verifyConcreteSetTypes; + +function verifyReactAdapterCompatibility() { + const provider: UseStorageProvider = storage; + + useStorage<"light" | "dark">({ + key: "theme", + storage: provider, + defaultValue: "light", + }); +} + +void verifyReactAdapterCompatibility; diff --git a/tests/factories.types.ts b/tests/factories.types.ts new file mode 100644 index 0000000..dbc9f9e --- /dev/null +++ b/tests/factories.types.ts @@ -0,0 +1,79 @@ +import { + SecureStorage, + Storage, + type SecureStorageOptions, + type StorageOptions, + type StorageProvider, +} from "../src"; + +interface State { + count?: number; + theme?: "light" | "dark"; +} + +const session: StorageProvider = Storage.Session(); + +const secureMake: StorageProvider = SecureStorage.make({ + area: "local", + namespace: "auth", + secureKey: "AppSecret", +}); +const secureLocal: StorageProvider = SecureStorage.Local({ + namespace: "auth", + secureKey: "AppSecret", +}); +const secureSession: StorageProvider = SecureStorage.Session({ + namespace: "auth", + secureKey: "AppSecret", +}); +const secureSync: StorageProvider = SecureStorage.Sync({ + namespace: "auth", + secureKey: "AppSecret", +}); +const secureManaged: StorageProvider = SecureStorage.Managed({ + namespace: "auth", + secureKey: "AppSecret", +}); + +const storageOptions: StorageOptions = { + area: "session", + namespace: "settings", +}; +const secureStorageOptions: SecureStorageOptions = { + area: "sync", + namespace: "auth", + secureKey: "AppSecret", +}; + +const storageFromOptions = new Storage(storageOptions); +const secureStorageFromOptions = new SecureStorage(secureStorageOptions); + +// @ts-expect-error area is selected by the Local shortcut +Storage.Local({area: "sync"}); +// @ts-expect-error area is selected by the Session shortcut +Storage.Session({area: "local"}); +// @ts-expect-error area is selected by the Sync shortcut +Storage.Sync({area: "local"}); +// @ts-expect-error area is selected by the Managed shortcut +Storage.Managed({area: "local"}); + +// @ts-expect-error area is selected by the Local shortcut +SecureStorage.Local({area: "sync", secureKey: "AppSecret"}); +// @ts-expect-error area is selected by the Session shortcut +SecureStorage.Session({area: "local", secureKey: "AppSecret"}); +// @ts-expect-error area is selected by the Sync shortcut +SecureStorage.Sync({area: "local", secureKey: "AppSecret"}); +// @ts-expect-error area is selected by the Managed shortcut +SecureStorage.Managed({area: "local", secureKey: "AppSecret"}); + +// @ts-expect-error secureKey is only supported by SecureStorage +Storage.Local({secureKey: "AppSecret"}); + +void session; +void secureMake; +void secureLocal; +void secureSession; +void secureSync; +void secureManaged; +void storageFromOptions; +void secureStorageFromOptions; diff --git a/tests/helpers/async.ts b/tests/helpers/async.ts new file mode 100644 index 0000000..0ab54ef --- /dev/null +++ b/tests/helpers/async.ts @@ -0,0 +1,38 @@ +export interface UnhandledErrorCapture { + readonly pending: number; + runNext(): void; + restore(): void; +} + +export const captureUnhandledErrors = (): UnhandledErrorCapture => { + const scheduled: VoidFunction[] = []; + const queueMicrotaskSpy = jest + .spyOn(globalThis, "queueMicrotask") + .mockImplementation(callback => scheduled.push(callback)); + let restored = false; + + return { + get pending(): number { + return scheduled.length; + }, + runNext(): void { + const callback = scheduled.shift(); + + if (!callback) { + throw new Error("No unhandled error is queued."); + } + + callback(); + }, + restore(): void { + if (restored) { + return; + } + + restored = true; + queueMicrotaskSpy.mockRestore(); + }, + }; +}; + +export const flushMacrotask = (): Promise => new Promise(resolve => setTimeout(resolve, 0)); diff --git a/tests/helpers/webLocks.ts b/tests/helpers/webLocks.ts new file mode 100644 index 0000000..2d66308 --- /dev/null +++ b/tests/helpers/webLocks.ts @@ -0,0 +1,98 @@ +export type WebLocksMock = Pick; + +const createAbortError = (): Error => { + const error = new Error("The lock request was aborted."); + error.name = "AbortError"; + + return error; +}; + +export const createWebLocksMock = (): WebLocksMock => { + const tails = new Map>(); + + const request = async ( + name: string, + optionsOrCallback: LockOptions | LockGrantedCallback, + maybeCallback?: LockGrantedCallback + ): Promise => { + const callback = typeof optionsOrCallback === "function" ? optionsOrCallback : maybeCallback; + const options = typeof optionsOrCallback === "function" ? {} : optionsOrCallback; + + if (!callback) { + throw new TypeError("Lock callback is required."); + } + + const signal = options.signal; + + if (signal?.aborted) { + throw createAbortError(); + } + + const previous = tails.get(name) ?? Promise.resolve(); + let releaseCurrent: VoidFunction | undefined; + + const current = new Promise(resolve => { + releaseCurrent = resolve; + }); + const tail = previous.then(() => current); + + tails.set(name, tail); + + try { + await new Promise((resolve, reject) => { + let settled = false; + + const cleanup = () => { + signal?.removeEventListener("abort", onAbort); + }; + const resolveOnce = () => { + if (settled) { + return; + } + + settled = true; + cleanup(); + resolve(); + }; + const rejectOnce = (reason: unknown) => { + if (settled) { + return; + } + + settled = true; + cleanup(); + reject(reason); + }; + const onAbort = () => rejectOnce(createAbortError()); + + signal?.addEventListener("abort", onAbort, {once: true}); + + void previous.then( + () => { + if (signal?.aborted) { + rejectOnce(createAbortError()); + return; + } + + resolveOnce(); + }, + rejectOnce + ); + }); + + return await callback({name, mode: options.mode ?? "exclusive"} as Lock); + } finally { + releaseCurrent?.(); + + void tail.then(() => { + if (tails.get(name) === tail) { + tails.delete(name); + } + }); + } + }; + + return { + request: request as WebLocksMock["request"], + }; +}; diff --git a/tests/jest.globals.d.ts b/tests/jest.globals.d.ts index 5023f76..cb3de6c 100644 --- a/tests/jest.globals.d.ts +++ b/tests/jest.globals.d.ts @@ -1,21 +1,35 @@ -import type {StorageProvider, StorageState} from "../src"; +export {}; declare global { - var storageLocalGet: (key: string | string[], storage?: StorageProvider) => Promise; + var resetStorageChangeListeners: () => void; + + var storageLocalGet: (key: string | string[], storage?: object) => Promise; var simulateStorageChange: (params: { - storage: StorageProvider; + storage: object; key: string; oldValue: any; newValue: any; areaName?: chrome.storage.AreaName; }) => void; + var simulateStorageChanges: (params: { + storage: object; + changes: Record; + areaName?: chrome.storage.AreaName; + }) => void; + var simulateSecureStorageChange: (params: { - storage: StorageProvider; + storage: object; key: string; oldValue: any; newValue: any; areaName?: chrome.storage.AreaName; }) => Promise; + + var simulateSecureStorageChanges: (params: { + storage: object; + changes: Record; + areaName?: chrome.storage.AreaName; + }) => Promise; } diff --git a/tests/jest.storage.setup.ts b/tests/jest.storage.setup.ts index 5b27581..5756ab6 100644 --- a/tests/jest.storage.setup.ts +++ b/tests/jest.storage.setup.ts @@ -1,12 +1,23 @@ import "jest-webextension-mock"; import {TextDecoder, TextEncoder} from "util"; - -import type {StorageProvider, StorageState} from "../src"; +import {flushMacrotask} from "./helpers/async"; +import {createWebLocksMock} from "./helpers/webLocks"; type Listener = (changes: Record, areaName: chrome.storage.AreaName) => void; const listeners = new Set(); +const hasOwn = (value: object, key: PropertyKey): boolean => Object.getOwnPropertyDescriptor(value, key) !== undefined; + +const setRecordValue = (target: object, key: PropertyKey, value: unknown): void => { + Object.defineProperty(target, key, { + configurable: true, + enumerable: true, + value, + writable: true, + }); +}; + const createStorageArea = (): chrome.storage.StorageArea => { let data: Record = {}; @@ -16,18 +27,21 @@ const createStorageArea = (): chrome.storage.StorageArea => { } if (typeof keys === "string") { - return {[keys]: data[keys]}; + return hasOwn(data, keys) ? {[keys]: data[keys]} : {}; } if (Array.isArray(keys)) { return keys.reduce>((acc, key) => { - acc[key] = data[key]; + if (hasOwn(data, key)) { + setRecordValue(acc, key, data[key]); + } + return acc; }, {}); } return Object.entries(keys).reduce>((acc, [key, fallbackValue]) => { - acc[key] = key in data ? data[key] : fallbackValue; + setRecordValue(acc, key, hasOwn(data, key) ? data[key] : fallbackValue); return acc; }, {}); }; @@ -62,7 +76,15 @@ const createStorageArea = (): chrome.storage.StorageArea => { return Promise.resolve(0); }) as unknown as chrome.storage.StorageArea["getBytesInUse"], set: jest.fn((items: Record, callback?: () => void) => { - data = {...data, ...items}; + const next = {...data}; + + for (const [key, value] of Object.entries(items)) { + if (value !== undefined) { + setRecordValue(next, key, value); + } + } + + data = next; if (callback) { callback(); @@ -142,8 +164,12 @@ chrome.storage.onChanged.addListener = jest.fn(cb => listeners.add(cb)); chrome.storage.onChanged.removeListener = jest.fn(cb => listeners.delete(cb)); chrome.storage.onChanged.hasListener = jest.fn(cb => listeners.has(cb)); +global.resetStorageChangeListeners = () => { + listeners.clear(); +}; + interface StorageChange { - storage: StorageProvider; + storage: object; key: string; oldValue: any; newValue: any; @@ -151,32 +177,57 @@ interface StorageChange { } global.simulateStorageChange = ({storage, key, oldValue, newValue, areaName = "local"}: StorageChange) => { - const fullKey = (storage as any)["getFullKey"](key); + global.simulateStorageChanges({ + storage, + changes: {[key]: {oldValue, newValue}}, + areaName, + }); +}; - const changes = {[fullKey]: {oldValue, newValue}}; +global.simulateStorageChanges = ({storage, changes, areaName = "local"}) => { + const formattedChanges = Object.entries(changes).reduce>( + (acc, [key, change]) => { + const fullKey = (storage as any)["getFullKey"](key); + setRecordValue(acc, fullKey, change); + + return acc; + }, + {} + ); - listeners.forEach(listener => listener(changes, areaName)); + listeners.forEach(listener => listener(formattedChanges, areaName)); }; global.simulateSecureStorageChange = async ({storage, key, oldValue, newValue, areaName}: StorageChange) => { - const encryptedOldValue = oldValue !== undefined ? await (storage as any)["encrypt"](oldValue) : undefined; - const encryptedNewValue = newValue !== undefined ? await (storage as any)["encrypt"](newValue) : undefined; - - global.simulateStorageChange({ + await global.simulateSecureStorageChanges({ storage, - key, - oldValue: encryptedOldValue, - newValue: encryptedNewValue, + changes: {[key]: {oldValue, newValue}}, areaName, }); +}; - await new Promise(resolve => setTimeout(resolve)); +global.simulateSecureStorageChanges = async ({storage, changes, areaName = "local"}) => { + const encryptedChanges = Object.fromEntries( + await Promise.all( + Object.entries(changes).map(async ([key, {oldValue, newValue}]) => [ + key, + { + oldValue: oldValue !== undefined ? await (storage as any)["encrypt"](oldValue) : undefined, + newValue: newValue !== undefined ? await (storage as any)["encrypt"](newValue) : undefined, + }, + ] as const) + ) + ); + + global.simulateStorageChanges({storage, changes: encryptedChanges, areaName}); + + await flushMacrotask(); }; // Needed to access a specific key in Storage // Native GET method does not work correctly with a specific key other than "key" // Pull Request with bug fix - https://github.com/RickyMarou/jest-webextension-mock/pull/19 -global.storageLocalGet = (key: string | string[], storage?: StorageProvider): Promise => { +global.storageLocalGet = (key: string | string[], storage?: object): Promise => { const formatKey = (k: string) => (storage ? (storage as any)["getFullKey"](k) : k); return new Promise(resolve => { chrome.storage.local.get(null, res => { @@ -256,80 +307,8 @@ Object.defineProperty(globalThis, "crypto", { configurable: true, }); -const lockQueues = new Map>(); - -const createAbortError = () => { - const error = new Error("The lock request was aborted."); - error.name = "AbortError"; - - return error; -}; - -const requestLock: LockManager["request"] = async ( - name: string, - optionsOrCallback: LockOptions | LockGrantedCallback, - maybeCallback?: LockGrantedCallback -): Promise => { - const callback = typeof optionsOrCallback === "function" ? optionsOrCallback : maybeCallback; - const options = typeof optionsOrCallback === "function" ? {} : optionsOrCallback; - - if (!callback) { - throw new Error("Lock callback is required."); - } - - const signal = options?.signal; - - if (signal?.aborted) { - throw createAbortError(); - } - - const previous = lockQueues.get(name) ?? Promise.resolve(); - - let release: (() => void) | undefined; - - const current = new Promise(resolve => { - release = resolve; - }); - - lockQueues.set(name, previous.then(() => current)); - - await new Promise((resolve, reject) => { - const onAbort = () => reject(createAbortError()); - - signal?.addEventListener("abort", onAbort, {once: true}); - - previous.then( - () => { - signal?.removeEventListener("abort", onAbort); - - if (signal?.aborted) { - reject(createAbortError()); - return; - } - - resolve(); - }, - reject - ); - }); - - try { - return await callback({name, mode: options?.mode ?? "exclusive"} as Lock); - } finally { - release?.(); - - if (lockQueues.get(name) === current) { - lockQueues.delete(name); - } - } -}; - -const locksMock: Pick = { - request: requestLock, -}; - Object.defineProperty(globalThis.navigator, "locks", { - value: locksMock, + value: createWebLocksMock(), writable: true, enumerable: true, configurable: true, From 96c550cdcdecc87e9ab5a5f119a10022a5c7636d Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:17:20 +0300 Subject: [PATCH 2/8] chore(release): align breaking-change bumps before 1.0 - treat breaking commits as minor releases on 0.x and major releases on 1.x - recognize bang syntax, parser flags, notes, and breaking footers consistently - preserve the minor release policy for revert commits - add focused release-policy tests and contributor documentation --- .release-it.cjs | 97 ++++++++++++++++++++++++++++------------ CONTRIBUTING.md | 7 +-- tests/release-it.test.ts | 62 +++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 32 deletions(-) create mode 100644 tests/release-it.test.ts 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/tests/release-it.test.ts b/tests/release-it.test.ts new file mode 100644 index 0000000..362fa03 --- /dev/null +++ b/tests/release-it.test.ts @@ -0,0 +1,62 @@ +type ReleaseCommit = { + type?: string; + header?: string; + breaking?: string | boolean; + footer?: string; + notes?: Array<{title?: string; text?: string}>; +}; + +type Bump = {level: 0 | 1 | 2} | null; + +const {whatBump} = require("../.release-it.cjs") as { + whatBump: (commits: ReleaseCommit[], currentVersion?: string) => Bump; +}; + +describe("release-it version policy", () => { + describe("breaking changes", () => { + test.each([ + ["parser breaking field", {type: "feat", breaking: "!"}], + ["type suffix", {type: "feat!"}], + ["header suffix", {type: "feat", header: "feat(storage)!: remove legacy API"}], + ["BREAKING CHANGE note", {type: "fix", notes: [{title: "BREAKING CHANGE", text: "new contract"}]}], + ["BREAKING-CHANGE footer", {type: "fix", footer: "BREAKING-CHANGE: new contract"}], + ])("treats %s as a pre-1.0 minor bump", (_label, commit) => { + expect(whatBump([commit], "0.6.0")).toEqual({level: 1}); + }); + + test("becomes a major bump after 1.0", () => { + expect( + whatBump([{type: "fix", notes: [{title: "BREAKING CHANGE", text: "new contract"}]}], "1.4.2") + ).toEqual({level: 0}); + }); + + test("takes precedence over lower-level changes after 1.0", () => { + expect( + whatBump( + [ + {type: "fix"}, + {type: "feat"}, + {type: "refactor", breaking: true}, + ], + "2.0.0" + ) + ).toEqual({level: 0}); + }); + }); + + test.each(["feat", "revert"])("uses a minor bump for %s", type => { + expect(whatBump([{type}], "0.6.0")).toEqual({level: 1}); + }); + + test.each(["fix", "perf", "refactor", "ci"])("uses a patch bump for %s", type => { + expect(whatBump([{type}], "0.6.0")).toEqual({level: 2}); + }); + + test("uses the highest non-breaking bump", () => { + expect(whatBump([{type: "fix"}, {type: "feat"}], "0.6.0")).toEqual({level: 1}); + }); + + test.each(["docs", "test", "chore", "build"])("does not release for %s alone", type => { + expect(whatBump([{type}], "0.6.0")).toBeNull(); + }); +}); From daecdaa5255a8904fe3ca34661f38c0c14f856ab Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:29:45 +0300 Subject: [PATCH 3/8] refactor(locking)!: rename LockManager to WebLockManager - move the Web Locks implementation and tests into src/locking - add a locking barrel and re-export built-in lockers from the package root - keep WebLockManager as the default locker for storage providers - document the built-in implementation and verify its public type export BREAKING CHANGE: LockManager is now exported as WebLockManager. Update package-root imports to use WebLockManager; the old LockManager alias is not retained. --- README.md | 3 +++ src/index.ts | 2 +- .../WebLockManager.test.ts} | 26 +++++++++---------- .../WebLockManager.ts} | 4 +-- src/locking/index.ts | 1 + src/providers/AbstractStorage.ts | 4 +-- tests/locking.types.ts | 5 ++++ 7 files changed, 27 insertions(+), 18 deletions(-) rename src/{LockManager.test.ts => locking/WebLockManager.test.ts} (91%) rename src/{LockManager.ts => locking/WebLockManager.ts} (94%) create mode 100644 src/locking/index.ts create mode 100644 tests/locking.types.ts diff --git a/README.md b/README.md index a61c21f..23ee636 100644 --- a/README.md +++ b/README.md @@ -584,6 +584,9 @@ Every storage instance exposes the same small API: ## Custom locking +Storage providers use the exported `WebLockManager` by default. It coordinates +updates through the native Web Locks API. + If you need custom lock behavior, pass your own `locker`: ```ts diff --git a/src/index.ts b/src/index.ts index 66824ca..ab88a5c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ export {StorageCorruptionError, StoragePartialUpdateError} from "./errors"; -export {default as LockManager} from "./LockManager"; +export {WebLockManager} from "./locking"; export {MonoStorage, SecureStorage, Storage} from "./providers"; export type {SecureStorageOptions, StorageOptions} from "./providers"; export type { diff --git a/src/LockManager.test.ts b/src/locking/WebLockManager.test.ts similarity index 91% rename from src/LockManager.test.ts rename to src/locking/WebLockManager.test.ts index 461dd9c..a3edddb 100644 --- a/src/LockManager.test.ts +++ b/src/locking/WebLockManager.test.ts @@ -1,7 +1,7 @@ -import {createWebLocksMock, type WebLocksMock} from "../tests/helpers/webLocks"; -import LockManager from "./LockManager"; +import {createWebLocksMock, type WebLocksMock} from "../../tests/helpers/webLocks"; +import WebLockManager from "./WebLockManager"; -class TestLockManager extends LockManager { +class TestWebLockManager extends WebLockManager { constructor(private readonly locks: WebLocksMock) { super("test"); } @@ -58,7 +58,7 @@ describe("createWebLocksMock", () => { }); }); -describe("LockManager", () => { +describe("WebLockManager", () => { const originalLocks = globalThis.navigator.locks; afterEach(() => { @@ -74,7 +74,7 @@ describe("LockManager", () => { }); test("runs tasks sequentially for the same lock name", async () => { - const lockManager = new TestLockManager(createWebLocksMock()); + const lockManager = new TestWebLockManager(createWebLocksMock()); const steps: string[] = []; await Promise.all([ @@ -100,7 +100,7 @@ describe("LockManager", () => { configurable: true, }); - const lockManager = new LockManager(); + const lockManager = new WebLockManager(); await expect(lockManager.request("profile", async () => "ok")).rejects.toThrow( "Lock-coordinated storage update is unavailable: Web Locks API is not supported in this context." @@ -108,7 +108,7 @@ describe("LockManager", () => { }); test("aborts while waiting for a queued lock", async () => { - const lockManager = new TestLockManager(createWebLocksMock()); + const lockManager = new TestWebLockManager(createWebLocksMock()); let releaseFirstLock: (() => void) | undefined; @@ -130,7 +130,7 @@ describe("LockManager", () => { }); test("keeps later requests queued after an earlier waiter aborts", async () => { - const lockManager = new TestLockManager(createWebLocksMock()); + const lockManager = new TestWebLockManager(createWebLocksMock()); const steps: string[] = []; let releaseFirstLock: (() => void) | undefined; @@ -182,7 +182,7 @@ describe("LockManager", () => { test("aborts when lock wait exceeds timeout", async () => { jest.useFakeTimers(); - const lockManager = new TestLockManager(createWebLocksMock()); + const lockManager = new TestWebLockManager(createWebLocksMock()); let releaseFirstLock: (() => void) | undefined; @@ -205,7 +205,7 @@ describe("LockManager", () => { test("cleans timeout and external abort listener when the lock is granted", async () => { jest.useFakeTimers(); - const lockManager = new TestLockManager(createWebLocksMock()); + const lockManager = new TestWebLockManager(createWebLocksMock()); const controller = new AbortController(); const removeEventListener = jest.spyOn(controller.signal, "removeEventListener"); @@ -245,7 +245,7 @@ describe("LockManager", () => { test("cleans timeout and external abort listener when lock acquisition is aborted", async () => { jest.useFakeTimers(); - const lockManager = new TestLockManager(createWebLocksMock()); + const lockManager = new TestWebLockManager(createWebLocksMock()); let releaseFirstLock: (() => void) | undefined; const firstTask = lockManager.request("settings", async () => { @@ -277,7 +277,7 @@ describe("LockManager", () => { const locks = { request: jest.fn().mockRejectedValue(new Error("Lock backend failed")), } as unknown as WebLocksMock; - const lockManager = new TestLockManager(locks); + const lockManager = new TestWebLockManager(locks); const controller = new AbortController(); const removeEventListener = jest.spyOn(controller.signal, "removeEventListener"); @@ -293,7 +293,7 @@ describe("LockManager", () => { }); test("releases the lock after a task failure", async () => { - const lockManager = new TestLockManager(createWebLocksMock()); + const lockManager = new TestWebLockManager(createWebLocksMock()); await expect( lockManager.request("settings", async () => { diff --git a/src/LockManager.ts b/src/locking/WebLockManager.ts similarity index 94% rename from src/LockManager.ts rename to src/locking/WebLockManager.ts index 07d58fd..6edc0f4 100644 --- a/src/LockManager.ts +++ b/src/locking/WebLockManager.ts @@ -1,11 +1,11 @@ -import type {StorageLocker, StorageLockOptions} from "./types"; +import type {StorageLocker, StorageLockOptions} from "../types"; interface LockRequestSignal { signal: AbortSignal | undefined; cleanup: () => void; } -export default class LockManager implements StorageLocker { +export default class WebLockManager implements StorageLocker { constructor(protected readonly prefix: string = "storage") {} public async request(name: string, task: () => Promise, options: StorageLockOptions = {}): Promise { diff --git a/src/locking/index.ts b/src/locking/index.ts new file mode 100644 index 0000000..17f712d --- /dev/null +++ b/src/locking/index.ts @@ -0,0 +1 @@ +export {default as WebLockManager} from "./WebLockManager"; diff --git a/src/providers/AbstractStorage.ts b/src/providers/AbstractStorage.ts index f6ae002..5087704 100644 --- a/src/providers/AbstractStorage.ts +++ b/src/providers/AbstractStorage.ts @@ -3,7 +3,7 @@ import {callWithPromise, handleListener} from "@addon-core/browser/utils"; import {dequal as defaultCompare} from "dequal/lite"; import {planBatchUpdate} from "../batch"; import {StoragePartialUpdateError} from "../errors"; -import LockManager from "../LockManager"; +import {WebLockManager} from "../locking"; import { assertStorageKey, assertStorageNamespace, @@ -170,7 +170,7 @@ export default abstract class AbstractStorage implements this.area = area ?? "local"; this.storage = storage()[this.area]; - this.locker = locker ?? new LockManager(`storage:${this.area}`); + this.locker = locker ?? new WebLockManager(`storage:${this.area}`); this.namespace = normalizedNamespace; } diff --git a/tests/locking.types.ts b/tests/locking.types.ts new file mode 100644 index 0000000..9d79ec9 --- /dev/null +++ b/tests/locking.types.ts @@ -0,0 +1,5 @@ +import {WebLockManager, type StorageLocker} from "../src"; + +const locker: StorageLocker = new WebLockManager(); + +void locker; From 70dce436df13769c7c95432ce1898890d769de7f Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:27:18 +0300 Subject: [PATCH 4/8] feat(types): support storage without explicit state - add default state generics across providers, factories, and related public types - preserve strict schema typing while documenting the loose playground mode - cover genericless and typed usage with compile-time assertions --- README.md | 24 ++++++ src/adapters/react/useStorage.ts | 4 +- src/errors.ts | 2 +- src/providers/AbstractStorage.ts | 14 ++-- src/providers/MonoStorage.ts | 4 +- src/providers/SecureStorage.ts | 32 +++++--- src/providers/Storage.ts | 2 +- src/types.ts | 21 ++--- tests/default-state.types.ts | 127 +++++++++++++++++++++++++++++++ 9 files changed, 198 insertions(+), 32 deletions(-) create mode 100644 tests/default-state.types.ts diff --git a/README.md b/README.md index 23ee636..74316b6 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ storage, and React bindings. ## Features - Typed single-key and batch overloads for `get`, `set`, and `update` +- Optional state contracts for both quick experimentation and strict typing - Lock-coordinated `update()` for race-safe single-key and batch writes - Per-key and per-event subscriptions through `watch()` and `subscribe()` - `local`, `session`, `sync`, and `managed` storage areas @@ -69,6 +70,29 @@ const token = await storage.get("token"); const all = await storage.getAll(); ``` +## Start without a state contract + +A state interface is optional. Omit the generic when experimenting or when the +stored shape is intentionally dynamic: + +```ts +import {Storage} from "@addon-core/storage"; + +const storage = Storage.Local({namespace: "playground"}); + +await storage.set("theme", "dark"); +await storage.set("attempts", 3); +await storage.set("profile", {name: "Ada"}); + +const theme = await storage.get("theme"); // any +``` + +This is a deliberately loose TypeScript mode: keys and values are not tied to a +compile-time schema. It does not relax the package's runtime validation—top-level +`undefined` is still rejected, invalid keys still throw, and the browser's +serialization rules still apply. Add a state interface when the shape becomes +stable and key/value mistakes should be caught by TypeScript. + ## Typed storage without boilerplate Define your storage shape once: diff --git a/src/adapters/react/useStorage.ts b/src/adapters/react/useStorage.ts index 5b3d120..83ae1ac 100644 --- a/src/adapters/react/useStorage.ts +++ b/src/adapters/react/useStorage.ts @@ -2,7 +2,7 @@ import {useCallback, useEffect, useMemo, useRef, useState} from "react"; import Storage from "../../providers/Storage"; import type {StorageProvider, StorageWatchOptions} from "../../types"; -export type UseStorageProvider = Pick>, "get" | "set" | "remove" | "watch">; +export type UseStorageProvider = Pick; export interface UseStorageOptions { key: string; @@ -30,7 +30,7 @@ function useStorage(arg1: string | UseStorageOptions, arg2?: T): Use const storageRef = useRef(null); if (storageRef.current === null) { - storageRef.current = options?.storage ?? Storage.Local>(); + storageRef.current = options?.storage ?? Storage.Local(); } const storage = storageRef.current; diff --git a/src/errors.ts b/src/errors.ts index 92af6ef..8ed3be8 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -15,7 +15,7 @@ export class StorageCorruptionError extends Error { } } -export class StoragePartialUpdateError extends Error { +export class StoragePartialUpdateError extends Error { public readonly appliedSetKeys: readonly (keyof T)[]; public readonly attemptedRemoveKeys: readonly (keyof T)[]; public readonly cause: unknown; diff --git a/src/providers/AbstractStorage.ts b/src/providers/AbstractStorage.ts index 5087704..27aff19 100644 --- a/src/providers/AbstractStorage.ts +++ b/src/providers/AbstractStorage.ts @@ -59,7 +59,7 @@ export type FactoryOptions = WithKey>; export type AreaOptions = OmitUndef, "area">; -export type StaticMake = < +export type StaticMake = < T extends new ( options?: O ) => StorageProvider, @@ -68,7 +68,7 @@ export type StaticMake = < options?: FactoryOptions ) => StorageProvider; -export default abstract class AbstractStorage implements StorageProvider { +export default abstract class AbstractStorage implements StorageProvider { private storage: StorageArea; private readonly area: AreaName; protected readonly locker: StorageLocker; @@ -81,7 +81,7 @@ export default abstract class AbstractStorage implements protected abstract decodeFullKey(fullKey: string): keyof T | null; public static make< - S extends StorageState, + S extends StorageState = StorageState, O extends StorageOptions = StorageOptions, T extends new ( options?: O @@ -105,7 +105,7 @@ export default abstract class AbstractStorage implements } public static Local< - S extends StorageState, + S extends StorageState = StorageState, O extends StorageOptions = StorageOptions, T extends new ( options?: O @@ -120,7 +120,7 @@ export default abstract class AbstractStorage implements } public static Session< - S extends StorageState, + S extends StorageState = StorageState, O extends StorageOptions = StorageOptions, T extends new ( options?: O @@ -135,7 +135,7 @@ export default abstract class AbstractStorage implements } public static Sync< - S extends StorageState, + S extends StorageState = StorageState, O extends StorageOptions = StorageOptions, T extends new ( options?: O @@ -150,7 +150,7 @@ export default abstract class AbstractStorage implements } public static Managed< - S extends StorageState, + S extends StorageState = StorageState, O extends StorageOptions = StorageOptions, T extends new ( options?: O diff --git a/src/providers/MonoStorage.ts b/src/providers/MonoStorage.ts index 3fe14e8..6f65723 100644 --- a/src/providers/MonoStorage.ts +++ b/src/providers/MonoStorage.ts @@ -36,7 +36,9 @@ import type { */ const isUnchangedBucket = (previousBucket: unknown, nextBucket: unknown): boolean => previousBucket === nextBucket; -export default class MonoStorage implements StorageProvider { +export default class MonoStorage + implements StorageProvider +{ constructor( public readonly key: K, protected readonly storage: StorageProvider>> diff --git a/src/providers/SecureStorage.ts b/src/providers/SecureStorage.ts index 2211dec..a924c67 100644 --- a/src/providers/SecureStorage.ts +++ b/src/providers/SecureStorage.ts @@ -20,15 +20,17 @@ export interface SecureStorageOptions extends StorageOptions { type SecureStorageFactoryOptions = SecureStorageOptions & {key?: string}; type SecureStorageAreaOptions = Omit; -export default class SecureStorage extends AbstractStorage { +export default class SecureStorage extends AbstractStorage { private readonly secureKey: string; private cryptoKey: CryptoKey | null = null; private cryptoKeyPromise: Promise | null = null; - public static override make(options?: SecureStorageFactoryOptions): StorageProvider; + public static override make( + options?: SecureStorageFactoryOptions + ): StorageProvider; public static override make< - S extends StorageState, + S extends StorageState = StorageState, O extends StorageOptions = StorageOptions, C extends new ( options?: O @@ -41,9 +43,11 @@ export default class SecureStorage extends AbstractStora return super.make(options); } - public static override Local(options?: SecureStorageAreaOptions): StorageProvider; + public static override Local( + options?: SecureStorageAreaOptions + ): StorageProvider; public static override Local< - S extends StorageState, + S extends StorageState = StorageState, O extends StorageOptions = StorageOptions, C extends new ( options?: O @@ -56,9 +60,11 @@ export default class SecureStorage extends AbstractStora return this.make({...options, area: "local"}); } - public static override Session(options?: SecureStorageAreaOptions): StorageProvider; + public static override Session( + options?: SecureStorageAreaOptions + ): StorageProvider; public static override Session< - S extends StorageState, + S extends StorageState = StorageState, O extends StorageOptions = StorageOptions, C extends new ( options?: O @@ -71,9 +77,11 @@ export default class SecureStorage extends AbstractStora return this.make({...options, area: "session"}); } - public static override Sync(options?: SecureStorageAreaOptions): StorageProvider; + public static override Sync( + options?: SecureStorageAreaOptions + ): StorageProvider; public static override Sync< - S extends StorageState, + S extends StorageState = StorageState, O extends StorageOptions = StorageOptions, C extends new ( options?: O @@ -86,9 +94,11 @@ export default class SecureStorage extends AbstractStora return this.make({...options, area: "sync"}); } - public static override Managed(options?: SecureStorageAreaOptions): StorageProvider; + public static override Managed( + options?: SecureStorageAreaOptions + ): StorageProvider; public static override Managed< - S extends StorageState, + S extends StorageState = StorageState, O extends StorageOptions = StorageOptions, C extends new ( options?: O diff --git a/src/providers/Storage.ts b/src/providers/Storage.ts index c4f5101..ba767ac 100644 --- a/src/providers/Storage.ts +++ b/src/providers/Storage.ts @@ -2,7 +2,7 @@ import {STORAGE_KEY_SEPARATOR} from "../constants"; import AbstractStorage, {type StorageOptions} from "./AbstractStorage"; import type {StorageLockOptions, StorageState} from "../types"; -export default class Storage extends AbstractStorage { +export default class Storage extends AbstractStorage { constructor(options: StorageOptions = {}) { super(options); } diff --git a/src/types.ts b/src/types.ts index 803d8b1..8359727 100644 --- a/src/types.ts +++ b/src/types.ts @@ -36,40 +36,43 @@ export interface StorageUpdateOptions extends StorageLockOptions { compare?: StorageUpdateComparer; } -export type StorageBatchUpdater = ( +export type StorageBatchUpdater = ( prev: Partial> ) => Partial> | Promise>>; -export interface StorageBatchUpdateOptions extends StorageLockOptions { +export interface StorageBatchUpdateOptions + extends StorageLockOptions { /** - * Per-key equality checks. Return `true` to skip the physical write for that key. + * Per-key equality checks. Return `true` to skip the physical writing for that key. */ compare?: Partial<{[P in K]: StorageUpdateComparer}>; } -export type StorageWatchCallback = ( +export type StorageWatchCallback = ( newValue: T[K] | undefined, oldValue: T[K] | undefined, key: K ) => StorageListenerResult; -export type StorageWatchKeyCallback = { +export type StorageWatchKeyCallback = { [K in keyof T]?: (newValue: T[K] | undefined, oldValue: T[K] | undefined) => StorageListenerResult; }; -export type StorageWatchOptions = StorageWatchKeyCallback | StorageWatchCallback; +export type StorageWatchOptions = StorageWatchKeyCallback | StorageWatchCallback; -export type StorageChanges = Partial<{ +export type StorageChanges = Partial<{ [K in keyof T]: { newValue: T[K] | undefined; oldValue: T[K] | undefined; }; }>; -export type StorageSubscriber = (changes: StorageChanges) => StorageListenerResult; +export type StorageSubscriber = ( + changes: StorageChanges +) => StorageListenerResult; // prettier-ignore -export interface StorageProvider { +export interface StorageProvider { set(key: K, value: StorageSetValue): Promise; set(values: Partial): Promise; diff --git a/tests/default-state.types.ts b/tests/default-state.types.ts new file mode 100644 index 0000000..ece7047 --- /dev/null +++ b/tests/default-state.types.ts @@ -0,0 +1,127 @@ +import { + MonoStorage, + SecureStorage, + Storage, + StoragePartialUpdateError, + type StorageBatchUpdateOptions, + type StorageBatchUpdater, + type StorageChanges, + type StorageProvider, + type StorageState, + type StorageSubscriber, + type StorageWatchOptions, +} from "../src"; + +type Equal = + (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 ? true : false; +type Expect = Value; +type IsAny = 0 extends 1 & Value ? true : false; + +const directStorage = new Storage(); +const directSecureStorage = new SecureStorage(); +const directMonoStorage = new MonoStorage("bucket", Storage.Local()); + +type DirectStorageDefault = Expect>>; +type DirectSecureStorageDefault = Expect>>; +type DirectMonoStorageDefault = Expect>>; + +const storageMake = Storage.make(); +const storageLocal = Storage.Local(); +const storageSession = Storage.Session(); +const storageSync = Storage.Sync(); +const storageManaged = Storage.Managed(); +const storageMono = Storage.Local({key: "bucket"}); + +const secureMake = SecureStorage.make({secureKey: "AppSecret"}); +const secureLocal = SecureStorage.Local({secureKey: "AppSecret"}); +const secureSession = SecureStorage.Session({secureKey: "AppSecret"}); +const secureSync = SecureStorage.Sync({secureKey: "AppSecret"}); +const secureManaged = SecureStorage.Managed({secureKey: "AppSecret"}); +const secureMono = SecureStorage.Local({key: "bucket", secureKey: "AppSecret"}); + +type StorageMakeDefault = Expect>>; +type StorageLocalDefault = Expect>>; +type StorageSessionDefault = Expect>>; +type StorageSyncDefault = Expect>>; +type StorageManagedDefault = Expect>>; +type StorageMonoDefault = Expect>>; +type SecureMakeDefault = Expect>>; +type SecureLocalDefault = Expect>>; +type SecureSessionDefault = Expect>>; +type SecureSyncDefault = Expect>>; +type SecureManagedDefault = Expect>>; +type SecureMonoDefault = Expect>>; + +declare const providerTypeWithoutGeneric: StorageProvider; +declare const storageTypeWithoutGeneric: Storage; +declare const secureStorageTypeWithoutGeneric: SecureStorage; +declare const monoStorageTypeWithoutGeneric: MonoStorage; +declare const changesTypeWithoutGeneric: StorageChanges; +declare const subscriberTypeWithoutGeneric: StorageSubscriber; +declare const watcherTypeWithoutGeneric: StorageWatchOptions; +declare const batchUpdaterTypeWithoutGeneric: StorageBatchUpdater; +declare const batchOptionsTypeWithoutGeneric: StorageBatchUpdateOptions; +declare const partialUpdateErrorTypeWithoutGeneric: StoragePartialUpdateError; + +void providerTypeWithoutGeneric; +void storageTypeWithoutGeneric; +void secureStorageTypeWithoutGeneric; +void monoStorageTypeWithoutGeneric; +void changesTypeWithoutGeneric; +void subscriberTypeWithoutGeneric; +void watcherTypeWithoutGeneric; +void batchUpdaterTypeWithoutGeneric; +void batchOptionsTypeWithoutGeneric; +void partialUpdateErrorTypeWithoutGeneric; + +async function verifyLooseProvider() { + const storage: StorageProvider = Storage.Local({namespace: "playground"}); + + await storage.set("theme", "dark"); + await storage.set("attempts", 3); + await storage.set("enabled", true); + await storage.set("nullable", null); + await storage.set("profile", {name: "Ada"}); + await storage.set("tags", ["browser", "extension"]); + + const value = await storage.get("theme"); + type LooseGetResult = Expect>; + + void (null as unknown as LooseGetResult); +} + +void verifyLooseProvider; + +interface SettingsState { + attempts?: number; + nullable?: string | null; + theme?: "light" | "dark"; +} + +const typedStorage = Storage.Local(); + +async function verifyTypedProvider() { + const theme = await typedStorage.get("theme"); + type TypedGetResult = Expect>; + + await typedStorage.set("theme", "dark"); + await typedStorage.set("nullable", null); + + // @ts-expect-error unknown keys remain rejected when a state contract is provided + await typedStorage.get("unknown"); + // @ts-expect-error values remain tied to their keys in strict mode + await typedStorage.set("attempts", "three"); + // @ts-expect-error undefined deletion remains limited to update/remove in strict mode + await typedStorage.set("theme", undefined); + + void (null as unknown as TypedGetResult); +} + +void verifyTypedProvider; + +const bucketProvider = Storage.Local>>(); +const inferredMonoStorage = new MonoStorage("bucket", bucketProvider); + +type InferredMonoStorage = Expect>>; + +void (null as unknown as InferredMonoStorage); From 5c2c43c6e719c9e3ef6ebec8790a64c05eba051c Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:04:04 +0300 Subject: [PATCH 5/8] feat(helpers): add functional storage API - add one-shot get/set and provider factories for all storage areas - support secure and mono providers through typed overloads - document provider reuse and cover runtime and type contracts --- README.md | 89 +++++++++++++++ src/helpers/index.ts | 13 +++ src/helpers/storage.test.ts | 191 +++++++++++++++++++++++++++++++++ src/helpers/storage.ts | 129 ++++++++++++++++++++++ src/index.ts | 14 +++ src/types.ts | 12 +++ tests/storage-helpers.types.ts | 123 +++++++++++++++++++++ 7 files changed, 571 insertions(+) create mode 100644 src/helpers/index.ts create mode 100644 src/helpers/storage.test.ts create mode 100644 src/helpers/storage.ts create mode 100644 tests/storage-helpers.types.ts diff --git a/README.md b/README.md index 74316b6..d628c90 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ storage, and React bindings. - Typed single-key and batch overloads for `get`, `set`, and `update` - Optional state contracts for both quick experimentation and strict typing +- Laravel-style functional helpers for provider creation and one-shot reads/writes - Lock-coordinated `update()` for race-safe single-key and batch writes - Per-key and per-event subscriptions through `watch()` and `subscribe()` - `local`, `session`, `sync`, and `managed` storage areas @@ -93,6 +94,94 @@ compile-time schema. It does not relax the package's runtime validation—top-le serialization rules still apply. Add a state interface when the shape becomes stable and key/value mistakes should be caught by TypeScript. +## Functional helpers + +Use the functional API when a class factory is more ceremony than the operation +needs: + +```ts +import { + storage, + storageLocal, + storageManaged, + storageSecure, + storageSession, + storageSync, +} from "@addon-core/storage"; + +await storageLocal("draft", "Hello"); + +const draft = await storageLocal("draft"); +// string | undefined + +const selected = await storageSync<{ + language?: string; + theme?: "light" | "dark"; +}>(["theme", "language"]); +// Partial<{language?: string; theme?: "light" | "dark"}> +``` + +With one string argument the generic describes the expected value. With a key +array it describes the returned map. Without a generic, one-shot operations use +the loose default state and values are `any`. + +Calling a helper without a key returns a real provider rather than a proxy or a +callable facade: + +```ts +interface UserSettings { + attempts?: number; + theme?: "light" | "dark"; +} + +const settings = storageSync({namespace: "settings"}); + +await settings.set("theme", "dark"); +await settings.update("attempts", value => (value ?? 0) + 1); +``` + +`storage()` selects local storage by default and accepts an explicit `area`. +`storageLocal()`, `storageSession()`, `storageSync()`, and `storageManaged()` +select a fixed area and therefore do not accept an `area` option. + +Secure helpers use one general function to avoid multiplying area-specific +exports: + +```ts +interface AuthState { + accessToken?: string; +} + +const auth = storageSecure({ + area: "session", + namespace: "auth", + secureKey: "AppSecret", +}); + +await auth.set("accessToken", "jwt-token"); +``` + +Pass `{key}` to any provider-form helper to create `MonoStorage`. A plain object +argument is always interpreted as helper options, so direct batch set is not a +helper overload. Create a provider and use its regular method instead: + +```ts +const local = storageLocal(); + +await local.set({ + attempts: 1, + theme: "dark", +}); +``` + +Every helper invocation creates a new provider. Keep the returned provider when +performing a series of operations, especially with namespaces, custom lockers, +or secure storage. In particular, every one-shot `storageSecure()` call creates +a new provider and repeats the SHA-256 digest and AES key import. Reuse one secure +provider for a series of encrypted operations. Top-level `undefined` remains +invalid for set operations, and managed storage retains its browser-enforced +read-only behavior. + ## Typed storage without boilerplate Define your storage shape once: diff --git a/src/helpers/index.ts b/src/helpers/index.ts new file mode 100644 index 0000000..98a3edc --- /dev/null +++ b/src/helpers/index.ts @@ -0,0 +1,13 @@ +export { + storage, + storageLocal, + storageManaged, + storageSecure, + storageSession, + storageSync, +} from "./storage"; +export type { + SecureStorageHelperOptions, + StorageAreaHelperOptions, + StorageHelperOptions, +} from "./storage"; diff --git a/src/helpers/storage.test.ts b/src/helpers/storage.test.ts new file mode 100644 index 0000000..ecd8049 --- /dev/null +++ b/src/helpers/storage.test.ts @@ -0,0 +1,191 @@ +import MonoStorage from "../providers/MonoStorage"; +import SecureStorage from "../providers/SecureStorage"; +import Storage from "../providers/Storage"; +import type {StorageHelper} from "../types"; +import { + storage, + storageLocal, + storageManaged, + storageSecure, + storageSession, + storageSync, +} from "./storage"; + +const areas = ["local", "session", "sync", "managed"] as const; + +const clearAreas = async (): Promise => { + await Promise.all( + areas.map( + area => + new Promise(resolve => { + chrome.storage[area].clear(resolve); + }) + ) + ); +}; + +const getAreaValues = async (area: (typeof areas)[number]): Promise> => + await new Promise(resolve => chrome.storage[area].get(null, resolve)); + +beforeEach(async () => { + await clearAreas(); + jest.clearAllMocks(); +}); + +test("returns real providers for plain, secure, options, and Mono calls", () => { + expect(storage()).toBeInstanceOf(Storage); + expect(storageLocal()).toBeInstanceOf(Storage); + expect(storageLocal(undefined)).toBeInstanceOf(Storage); + expect(storageLocal({namespace: "settings"})).toBeInstanceOf(Storage); + expect(storageLocal({key: "popup"})).toBeInstanceOf(MonoStorage); + expect(storageSecure()).toBeInstanceOf(SecureStorage); + expect(storageSecure({secureKey: "AppSecret"})).toBeInstanceOf(SecureStorage); + expect(storageSecure({key: "auth", secureKey: "AppSecret"})).toBeInstanceOf(MonoStorage); +}); + +test("creates a new provider for every provider-form call", () => { + expect(storageLocal()).not.toBe(storageLocal()); +}); + +test("supports one-shot single set, single get, and batch get", async () => { + await storageLocal("theme", "dark"); + await storageLocal("attempts", 3); + + await expect(storageLocal("theme")).resolves.toBe("dark"); + await expect(storageLocal("missing")).resolves.toBeUndefined(); + await expect(storageLocal(["theme", "attempts", "missing"] as const)).resolves.toEqual({ + attempts: 3, + theme: "dark", + }); +}); + +test.each([ + ["local", storageLocal], + ["session", storageSession], + ["sync", storageSync], + ["managed", storageManaged], +] as const)("storage%s uses its matching native area", async (area, helper) => { + await (helper as StorageHelper)("selectedArea", area); + + await expect(getAreaValues(area)).resolves.toMatchObject({selectedArea: area}); + + for (const otherArea of areas.filter(value => value !== area)) { + await expect(getAreaValues(otherArea)).resolves.not.toHaveProperty("selectedArea"); + } +}); + +test("storage accepts area while area-specific helpers reject it", async () => { + const sync = storage<{theme?: string}>({area: "sync", namespace: "settings"}); + + await sync.set("theme", "dark"); + + await expect(getAreaValues("sync")).resolves.toMatchObject({"settings:theme": "dark"}); + expect(() => (storageLocal as any)({area: "sync"})).toThrow( + 'storageLocal options contain an unsupported property "area".' + ); +}); + +test("forwards namespace, key, and locker options to existing factories", async () => { + const requests: string[] = []; + const locker = { + async request(name: string, task: () => Promise): Promise { + requests.push(name); + return await task(); + }, + }; + const namespaced = storageLocal<{count?: number}>({locker, namespace: "feature"}); + const mono = storageLocal<{theme?: string}>({key: "settings"}); + + await namespaced.update("count", value => (value ?? 0) + 1); + await mono.set("theme", "dark"); + + expect(requests).toEqual(["feature:count"]); + await expect(getAreaValues("local")).resolves.toMatchObject({ + "feature:count": 1, + settings: {theme: "dark"}, + }); +}); + +test("accepts null-prototype options", () => { + const options = Object.create(null) as {namespace?: string}; + options.namespace = "prototype-free"; + + expect(storageLocal(options)).toBeInstanceOf(Storage); +}); + +test.each([null, new Date(), () => undefined, 42])("rejects invalid single arguments: %p", argument => { + expect(() => (storageLocal as any)(argument)).toThrow( + "storageLocal expects options, a string key, or an array of string keys." + ); +}); + +test("rejects unknown options instead of treating an object as batch set", () => { + expect(() => (storageLocal as any)({theme: "dark"})).toThrow( + 'storageLocal options contain an unsupported property "theme".' + ); + expect(chrome.storage.local.set).not.toHaveBeenCalled(); +}); + +test("rejects invalid batch keys, set keys, and arity", () => { + expect(() => (storageLocal as any)(["theme", 1])).toThrow("storageLocal batch get keys must be strings."); + expect(() => (storageLocal as any)(["theme"], "dark")).toThrow( + "storageLocal set key must be a string." + ); + expect(() => (storageLocal as any)("theme", "dark", true)).toThrow( + "storageLocal expects zero, one, or two arguments." + ); +}); + +test("routes explicit undefined through set validation without native I/O", async () => { + await expect((storageLocal as any)("theme", undefined)).rejects.toThrow( + "Storage set value must not be undefined." + ); + expect(chrome.storage.local.set).not.toHaveBeenCalled(); +}); + +test("supports typed one-shot set and get with the default secure provider", async () => { + await storageSecure("token", "secret"); + + await expect(storageSecure("token")).resolves.toBe("secret"); + + const values = await getAreaValues("local"); + expect(typeof values["secure::token"]).toBe("string"); + expect(values["secure::token"]).not.toBe("secret"); +}); + +test("forwards area, namespace, and secureKey", async () => { + const digestSpy = crypto.subtle.digest as jest.Mock; + const auth = storageSecure<{token?: string}>({ + area: "session", + namespace: "auth", + secureKey: "ForwardedSecret", + }); + + await auth.set("token", "secret"); + + expect(digestSpy).toHaveBeenCalledWith("SHA-256", new TextEncoder().encode("ForwardedSecret")); + await expect(getAreaValues("session")).resolves.toHaveProperty("secure:auth:token"); + await expect(getAreaValues("local")).resolves.not.toHaveProperty("secure:auth:token"); +}); + +test("creates MonoStorage over SecureStorage through the key option", async () => { + const auth = storageSecure<{token?: string}>({ + area: "sync", + key: "auth", + secureKey: "AppSecret", + }); + + await auth.set("token", "secret"); + + await expect(auth.get("token")).resolves.toBe("secret"); + const values = await getAreaValues("sync"); + expect(typeof values["secure::auth"]).toBe("string"); +}); + +test("rejects unknown secure options before encryption or native I/O", () => { + expect(() => (storageSecure as any)({token: "secret"})).toThrow( + 'storageSecure options contain an unsupported property "token".' + ); + expect(crypto.subtle.encrypt).not.toHaveBeenCalled(); + expect(chrome.storage.local.set).not.toHaveBeenCalled(); +}); diff --git a/src/helpers/storage.ts b/src/helpers/storage.ts new file mode 100644 index 0000000..90c97dc --- /dev/null +++ b/src/helpers/storage.ts @@ -0,0 +1,129 @@ +import {SecureStorage, type SecureStorageOptions, Storage, type StorageOptions} from "../providers"; +import {isPlainObject} from "../utils"; +import type {StorageHelper, StorageProvider, StorageState} from "../types"; + +type StorageProviderFactory = ( + options?: Options +) => StorageProvider; + +export type StorageHelperOptions = StorageOptions & {key?: string}; + +export type StorageAreaHelperOptions = Omit; + +export type SecureStorageHelperOptions = SecureStorageOptions & {key?: string}; + +const storageOptionKeys = ["area", "key", "locker", "namespace"] as const; +const storageAreaOptionKeys = ["key", "locker", "namespace"] as const; +const secureStorageOptionKeys = ["area", "key", "locker", "namespace", "secureKey"] as const; + +const assertOptions = ( + helperName: string, + options: Record, + allowedOptionKeys: ReadonlySet +): void => { + const invalidKey = Object.keys(options).find(key => !allowedOptionKeys.has(key)); + + if (invalidKey !== undefined) { + throw new TypeError(`${helperName} options contain an unsupported property "${invalidKey}".`); + } +}; + +function assertBatchKeys(helperName: string, keys: readonly unknown[]): asserts keys is readonly string[] { + if (keys.some(key => typeof key !== "string")) { + throw new TypeError(`${helperName} batch get keys must be strings.`); + } +} + +function assertSetKey(helperName: string, key: unknown): asserts key is string { + if (typeof key !== "string") { + throw new TypeError(`${helperName} set key must be a string.`); + } +} + +const createHelper = ( + helperName: string, + allowedOptionKeys: readonly string[], + createProvider: StorageProviderFactory +): StorageHelper => { + const allowedKeys = new Set(allowedOptionKeys); + + const helper = (...args: unknown[]): unknown => { + if (args.length === 0 || (args.length === 1 && args[0] === undefined)) { + return createProvider(); + } + + if (args.length === 1) { + const [argument] = args; + + if (typeof argument === "string") { + return createProvider().get(argument); + } + + if (Array.isArray(argument)) { + assertBatchKeys(helperName, argument); + return createProvider().get(argument); + } + + if (isPlainObject(argument)) { + assertOptions(helperName, argument, allowedKeys); + return createProvider(argument as Options); + } + + throw new TypeError(`${helperName} expects options, a string key, or an array of string keys.`); + } + + if (args.length === 2) { + const [key, value] = args; + + assertSetKey(helperName, key); + + return createProvider().set(key, value); + } + + throw new TypeError(`${helperName} expects zero, one, or two arguments.`); + }; + + return helper as StorageHelper; +}; + +export const storage: StorageHelper = createHelper( + "storage", + storageOptionKeys, + (options?: StorageHelperOptions): StorageProvider => + Storage.make(options) +); + +export const storageLocal: StorageHelper = createHelper( + "storageLocal", + storageAreaOptionKeys, + (options?: StorageAreaHelperOptions): StorageProvider => + Storage.Local(options) +); + +export const storageSession: StorageHelper = createHelper( + "storageSession", + storageAreaOptionKeys, + (options?: StorageAreaHelperOptions): StorageProvider => + Storage.Session(options) +); + +export const storageSync: StorageHelper = createHelper( + "storageSync", + storageAreaOptionKeys, + (options?: StorageAreaHelperOptions): StorageProvider => + Storage.Sync(options) +); + +export const storageManaged: StorageHelper = createHelper( + "storageManaged", + storageAreaOptionKeys, + (options?: StorageAreaHelperOptions): StorageProvider => + Storage.Managed(options) +); + +export const storageSecure: StorageHelper = createHelper( + "storageSecure", + secureStorageOptionKeys, + (options?: SecureStorageHelperOptions): StorageProvider => + SecureStorage.make(options) +); diff --git a/src/index.ts b/src/index.ts index ab88a5c..990d24c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,25 @@ export {StorageCorruptionError, StoragePartialUpdateError} from "./errors"; +export { + storage, + storageLocal, + storageManaged, + storageSecure, + storageSession, + storageSync, +} from "./helpers"; export {WebLockManager} from "./locking"; export {MonoStorage, SecureStorage, Storage} from "./providers"; +export type { + SecureStorageHelperOptions, + StorageAreaHelperOptions, + StorageHelperOptions, +} from "./helpers"; export type {SecureStorageOptions, StorageOptions} from "./providers"; export type { StorageBatchUpdateOptions, StorageBatchUpdater, StorageChanges, + StorageHelper, StorageListenerResult, StorageLocker, StorageLockOptions, diff --git a/src/types.ts b/src/types.ts index 8359727..8e7fe8c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -103,3 +103,15 @@ export interface StorageProvider { subscribe(callback: StorageSubscriber): () => void; } + +export interface StorageHelper { + (options?: Options): StorageProvider; + + (key: string): Promise; + + (key: string, value: StorageSetValue): Promise; + + (keys: readonly Key[]): Promise>>; + + (keys: readonly (keyof Result)[]): Promise>; +} diff --git a/tests/storage-helpers.types.ts b/tests/storage-helpers.types.ts new file mode 100644 index 0000000..b0e8bb7 --- /dev/null +++ b/tests/storage-helpers.types.ts @@ -0,0 +1,123 @@ +import * as storageApi from "../src"; +import { + storage, + storageLocal, + storageManaged, + storageSecure, + storageSession, + storageSync, + type SecureStorageHelperOptions, + type StorageAreaHelperOptions, + type StorageHelper, + type StorageHelperOptions, + type StorageProvider, +} from "../src"; + +type Equal = + (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 ? true : false; +type Expect = Value; +type IsAny = 0 extends 1 & Value ? true : false; + +interface SettingsState { + attempts?: number; + nullable?: string | null; + theme?: "light" | "dark"; +} + +const defaultProvider = storageLocal(); +const typedProvider = storageLocal(); +const configuredProvider = storageSync({namespace: "settings"}); +const genericAreaProvider = storage({area: "session", namespace: "settings"}); +const monoProvider = storageLocal({key: "settings"}); +const secureProvider = storageSecure({ + area: "local", + key: "settings", + namespace: "auth", + secureKey: "AppSecret", +}); + +type DefaultProvider = Expect>; +type TypedProvider = Expect>>; +type ConfiguredProvider = Expect>>; +type GenericAreaProvider = Expect>>; +type MonoProvider = Expect>>; +type SecureProvider = Expect>>; + +const looseValue = storageLocal("theme"); +const typedValue = storageLocal<"light" | "dark">("theme"); + +type LooseValue = Expect>>; +type TypedValue = Expect, "light" | "dark" | undefined>>; + +void storageLocal("attempts", 3); +void storageLocal("nullable", null); + +// @ts-expect-error explicit value generic rejects a different set value +void storageLocal("attempts", "three"); +// @ts-expect-error explicit value generic preserves the undefined set restriction +void storageLocal("theme", undefined); + +const looseBatch = storageLocal(["theme", "attempts"] as const); +const typedBatch = storageSync<{ + attempts?: number; + theme?: "light" | "dark"; +}>(["theme", "attempts"] as const); + +type LooseBatch = Expect< + Equal, Partial>> +>; +type TypedBatch = Expect< + Equal< + Awaited, + Partial<{attempts?: number; theme?: "light" | "dark"}> + > +>; + +const helper: StorageHelper = storageLocal; +const generalOptions: StorageHelperOptions = {area: "sync", key: "settings"}; +const areaOptions: StorageAreaHelperOptions = {key: "settings", namespace: "feature"}; +const secureOptions: SecureStorageHelperOptions = { + area: "session", + secureKey: "AppSecret", +}; + +storage(generalOptions); +storageLocal(areaOptions); +storageSecure(secureOptions); + +storageSession(); +storageManaged(); + +// @ts-expect-error area is selected by the area-specific helper +storageLocal({area: "sync"}); +// @ts-expect-error secureKey is supported only by storageSecure +storage({secureKey: "AppSecret"}); +// @ts-expect-error secure area-specific helper variants are intentionally not exported +storageApi.storageLocalSecure; + +async function verifyStrictProvider() { + const theme = await typedProvider.get("theme"); + type Theme = Expect>; + + await typedProvider.set("theme", "dark"); + + // @ts-expect-error strict provider rejects unknown keys + await typedProvider.get("unknown"); + // @ts-expect-error strict provider rejects values from another key + await typedProvider.set("attempts", "three"); + + void (null as unknown as Theme); +} + +void verifyStrictProvider; +void helper; +void (null as unknown as DefaultProvider); +void (null as unknown as TypedProvider); +void (null as unknown as ConfiguredProvider); +void (null as unknown as GenericAreaProvider); +void (null as unknown as MonoProvider); +void (null as unknown as SecureProvider); +void (null as unknown as LooseValue); +void (null as unknown as TypedValue); +void (null as unknown as LooseBatch); +void (null as unknown as TypedBatch); From 18d9e785e14d1e1fd9d0c659959c62c0c71b361a Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Sat, 18 Jul 2026 10:39:40 +0300 Subject: [PATCH 6/8] feat!: support aggregate batch update comparison --- README.md | 49 ++++++++---- src/batch.test.ts | 97 ++++++++++++++++++++---- src/batch.ts | 18 +++-- src/index.ts | 2 + src/providers/AbstractStorage.ts | 2 +- src/providers/MonoStorage.test.ts | 54 ++++++++++--- src/providers/MonoStorage.ts | 8 +- src/providers/SecureStorage.test.ts | 55 ++++++++++++++ src/providers/Storage.test.ts | 113 ++++++++++++++++++---------- src/types.ts | 16 +++- tests/batch.types.ts | 37 +++++++-- tests/default-state.types.ts | 6 ++ 12 files changed, 357 insertions(+), 100 deletions(-) diff --git a/README.md b/README.md index d628c90..da4ec0e 100644 --- a/README.md +++ b/README.md @@ -275,12 +275,13 @@ key. Returning a key that was not included in the key list throws before any write is made. The returned object is the final snapshot of the selected keys; removed keys are omitted. -Lock acquisition and equality checks can be configured for the whole batch: +Lock acquisition and one aggregate equality check can be configured for the +whole batch: ```ts const controller = new AbortController(); -await settings.update( +const result = await settings.update( ["theme", "language"] as const, prev => ({ theme: "dark", @@ -289,17 +290,29 @@ await settings.update( { signal: controller.signal, timeout: 500, - compare: { - theme: (prev, next) => prev === next, - }, + compare: (prev, next) => + prev.theme === next.theme && + (prev.language ?? "en") === (next.language ?? "en"), } ); ``` -Keys use deep equality by default, just like `update()`. A key-specific -`compare` function returns `true` to treat that logical key as unchanged. For -`MonoStorage`, another changed key may still cause the shared bucket to be -written. +The batch comparer receives two complete snapshots of the selected keys. The +first is the stored snapshot passed to the updater. The second is the proposed +snapshot after merging the updater's patch: omitted keys stay unchanged and own +properties set to `undefined` are removed. + +Returning `true` skips the whole batch. Nothing is written or removed, and +`update()` resolves to the previous stored snapshot. Returning `false` applies +the complete explicit patch, including defined values that are deeply equal to +their stored values, and resolves to the resulting snapshot. The comparer makes +one decision for the batch; it is not a per-key filter. Select which keys to +change by including only those keys in the updater's patch. + +Without a custom batch comparer, each explicit patch value uses deep equality. +Only changed values are written, only existing keys requested for deletion are +removed, and equal or omitted keys cause no native write. This default keeps the +physical update minimal. The `timeout` applies to each selected lock acquisition, not as one deadline for the whole batch. Direct `set()` calls and raw native storage writes do not @@ -365,8 +378,10 @@ await storage.update( ### Custom compare -`update()` skips writes when the returned value is equal to the previous value. -Pass `compare` when a specific update needs custom equality rules. +`update()` skips writes when the value returned by the updater is equal to the +stored value. Pass `compare` when a specific update needs custom equality rules. +The comparer receives the stored previous value and the next value produced by +the updater. ```ts await storage.update( @@ -378,11 +393,13 @@ await storage.update( ); ``` -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 physical write. It cannot force -a logical notification: `storage.onChanged` contains no provenance for the -write, and equal logical values are filtered by observers. +If `compare` returns `true`, no write is made and `update()` resolves to the +previous stored value, not the value proposed by the updater. This also means no +`watch()` callbacks are triggered for that update. If it returns `false`, the +next value is written and returned. Use `compare: () => false` when you need to +force a physical write. It cannot force a logical notification: +`storage.onChanged` contains no provenance for the write, and equal logical +values are filtered by observers. ### Important note diff --git a/src/batch.test.ts b/src/batch.test.ts index ee876bf..b1d71fe 100644 --- a/src/batch.test.ts +++ b/src/batch.test.ts @@ -68,40 +68,97 @@ describe("planBatchUpdate", () => { expect(Object.keys(patch)).toEqual(["label", "missing"]); }); - test("uses own per-key comparers to skip or force writes", () => { - const countComparer = jest.fn(() => true); - const labelComparer = jest.fn(() => false); + test("aggregate comparer skips the whole patch using the merged candidate snapshot", () => { + const compare = jest.fn(() => true); const plan = planBatchUpdate( ["count", "label"], {count: 1, label: "same"}, - {count: 2, label: "same"}, - {count: countComparer, label: labelComparer} + {count: 2}, + compare ); - expect(countComparer).toHaveBeenCalledWith(1, 2); - expect(labelComparer).toHaveBeenCalledWith("same", "same"); + expect(compare).toHaveBeenCalledTimes(1); + expect(compare).toHaveBeenCalledWith({count: 1, label: "same"}, {count: 2, label: "same"}); expect(plan).toEqual({ next: {count: 1, label: "same"}, - valuesToSet: {label: "same"}, + valuesToSet: {}, + keysToRemove: [], + }); + }); + + test("aggregate comparer can force all explicit values to be written", () => { + const compare = jest.fn(() => false); + + const plan = planBatchUpdate( + ["count", "label"], + {count: 1, label: "same"}, + {count: 1, label: "same"}, + compare + ); + + expect(compare).toHaveBeenCalledTimes(1); + expect(compare).toHaveBeenCalledWith({count: 1, label: "same"}, {count: 1, label: "same"}); + expect(plan).toEqual({ + next: {count: 1, label: "same"}, + valuesToSet: {count: 1, label: "same"}, keysToRemove: [], }); }); - test("ignores inherited comparer functions", () => { - const inheritedComparer = jest.fn(() => true); - const compare = Object.create({count: inheritedComparer}); + test("aggregate comparer sees deletions and can accept the whole mixed patch", () => { + const compare = jest.fn(() => false); - const plan = planBatchUpdate(["count"], {count: 1}, {count: 2}, compare); + const plan = planBatchUpdate( + ["count", "label", "missing"], + {count: 1, label: "stored"}, + {count: 1, label: undefined, missing: undefined}, + compare + ); - expect(inheritedComparer).not.toHaveBeenCalled(); + expect(compare).toHaveBeenCalledWith({count: 1, label: "stored"}, {count: 1}); expect(plan).toEqual({ - next: {count: 2}, + next: {count: 1}, + valuesToSet: {count: 1}, + keysToRemove: ["label"], + }); + }); + + test("aggregate comparer receives defensive snapshots that cannot change the plan", () => { + type Snapshot = Readonly>>; + const compare = jest.fn((previous: Snapshot, next: Snapshot) => { + (previous as Partial).count = 99; + (next as Partial).label = "mutated"; + return false; + }); + + const plan = planBatchUpdate( + ["count", "label"], + {count: 1, label: "before"}, + {count: 2}, + compare + ); + + expect(plan).toEqual({ + next: {count: 2, label: "before"}, valuesToSet: {count: 2}, keysToRemove: [], }); }); + test("aggregate comparer runs for an empty patch without forcing I/O", () => { + const compare = jest.fn(() => false); + + const plan = planBatchUpdate(["count"], {count: 1}, {}, compare); + + expect(compare).toHaveBeenCalledWith({count: 1}, {count: 1}); + expect(plan).toEqual({ + next: {count: 1}, + valuesToSet: {}, + keysToRemove: [], + }); + }); + test.each([null, [], "patch", 42, new Date()])("rejects a non-plain-object patch %#", patch => { expect(() => planBatchUpdate(["count"], {count: 1}, patch as never)).toThrow( new TypeError("Storage batch updater must return an object patch.") @@ -109,9 +166,17 @@ describe("planBatchUpdate", () => { }); test("rejects an own patch key outside the selected keys", () => { + const compare = jest.fn(() => false); + expect(() => - planBatchUpdate(["count"], {count: 1}, {label: "unexpected"} as never) + planBatchUpdate( + ["count"], + {count: 1}, + {label: "unexpected"} as never, + compare + ) ).toThrow('Storage batch updater returned an unrequested key: "label".'); + expect(compare).not.toHaveBeenCalled(); }); test("preserves prototype-like keys as own data properties without changing object prototypes", () => { @@ -135,7 +200,7 @@ describe("planBatchUpdate", () => { }); } - const plan = planBatchUpdate(keys, previous, patch, {}); + const plan = planBatchUpdate(keys, previous, patch); expect(Object.getPrototypeOf(plan.next)).toBe(Object.prototype); expect(Object.getPrototypeOf(plan.valuesToSet)).toBe(Object.prototype); diff --git a/src/batch.ts b/src/batch.ts index bdfd341..e8bc936 100644 --- a/src/batch.ts +++ b/src/batch.ts @@ -1,6 +1,6 @@ import {dequal as defaultCompare} from "dequal/lite"; -import {copyRecord, createRecord, hasOwn, isPlainObject, setRecordValue} from "./utils"; -import type {StorageBatchUpdateOptions, StorageState} from "./types"; +import {copyRecord, copyRecordWithoutPrototype, createRecord, hasOwn, isPlainObject, setRecordValue} from "./utils"; +import type {StorageBatchUpdateComparer, StorageState} from "./types"; export interface StorageBatchPlan { next: Partial>; @@ -12,7 +12,7 @@ export const planBatchUpdate = ( uniqueKeys: readonly K[], previous: Partial>, patch: Partial>, - compare?: StorageBatchUpdateOptions["compare"] + compare?: StorageBatchUpdateComparer ): StorageBatchPlan => { if (!isPlainObject(patch)) { throw new TypeError("Storage batch updater must return an object patch."); @@ -48,9 +48,7 @@ export const planBatchUpdate = ( continue; } - const compareValue = compare && hasOwn(compare, key) ? compare[key] : undefined; - - if ((compareValue ?? defaultCompare)(previousValue, nextValue)) { + if (!compare && defaultCompare(previousValue, nextValue)) { continue; } @@ -58,5 +56,13 @@ export const planBatchUpdate = ( setRecordValue(next, key, nextValue); } + if (compare?.(copyRecordWithoutPrototype(previous), copyRecordWithoutPrototype(next))) { + return { + next: copyRecord(previous), + valuesToSet: createRecord>>(), + keysToRemove: [], + }; + } + return {next, valuesToSet, keysToRemove}; }; diff --git a/src/index.ts b/src/index.ts index 990d24c..bbce099 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,8 @@ export type { } from "./helpers"; export type {SecureStorageOptions, StorageOptions} from "./providers"; export type { + StorageBatchSnapshot, + StorageBatchUpdateComparer, StorageBatchUpdateOptions, StorageBatchUpdater, StorageChanges, diff --git a/src/providers/AbstractStorage.ts b/src/providers/AbstractStorage.ts index 27aff19..1b4540e 100644 --- a/src/providers/AbstractStorage.ts +++ b/src/providers/AbstractStorage.ts @@ -246,7 +246,7 @@ export default abstract class AbstractStorage { expect(removeSpy).toHaveBeenCalledWith("bucket", expect.any(Function)); }); - test("batch update applies per-key comparers before deciding whether to write the bucket", async () => { + test("aggregate comparer can force all explicit values into one physical bucket write", async () => { const underlying = new Storage>>(); const mono = new MonoStorage(key, underlying); await mono.set({a: 1, b: 2}); - const compareA = jest.fn(() => true); - const compareB = jest.fn(() => false); + const compare = jest.fn(() => false); const setSpy = chrome.storage.local.set as jest.Mock; setSpy.mockClear(); const result = await mono.update( ["a", "b"] as const, - () => ({a: 2, b: 2}), - {compare: {a: compareA, b: compareB}} + () => ({a: 1, b: 3}), + {compare} ); - expect(compareA).toHaveBeenCalledWith(1, 2); - expect(compareB).toHaveBeenCalledWith(2, 2); - expect(result).toEqual({a: 1, b: 2}); + expect(compare).toHaveBeenCalledTimes(1); + expect(compare).toHaveBeenCalledWith({a: 1, b: 2}, {a: 1, b: 3}); + expect(result).toEqual({a: 1, b: 3}); expect(setSpy).toHaveBeenCalledTimes(1); - expect(setSpy).toHaveBeenCalledWith({bucket: {a: 1, b: 2}}, expect.any(Function)); + expect(setSpy).toHaveBeenCalledWith({bucket: {a: 1, b: 3}}, expect.any(Function)); + }); + + test("aggregate comparer can skip the whole logical patch without writing the bucket", async () => { + const underlying = new Storage>>(); + const mono = new MonoStorage(key, underlying); + await mono.set({a: 1, b: 2}); + + const compare = jest.fn(() => true); + const setSpy = chrome.storage.local.set as jest.Mock; + setSpy.mockClear(); + + const result = await mono.update( + ["a", "b"] as const, + () => ({a: undefined, b: 3}), + {compare} + ); + + expect(compare).toHaveBeenCalledWith({a: 1, b: 2}, {b: 3}); + expect(result).toEqual({a: 1, b: 2}); + expect(setSpy).not.toHaveBeenCalled(); }); test("batch update rejects unrequested keys without changing the bucket", async () => { @@ -252,6 +271,7 @@ describe("batch overloads", () => { const underlying = new Storage>>(); const mono = new MonoStorage(key, underlying); const updater = jest.fn(() => ({})); + const compare = jest.fn(() => false); const getSpy = chrome.storage.local.get as jest.Mock; const setSpy = chrome.storage.local.set as jest.Mock; const removeSpy = chrome.storage.local.remove as jest.Mock; @@ -261,10 +281,11 @@ describe("batch overloads", () => { await expect(mono.get([])).resolves.toEqual({}); await expect(mono.set({})).resolves.toBeUndefined(); - await expect(mono.update([], updater)).resolves.toEqual({}); + await expect(mono.update([], updater, {compare})).resolves.toEqual({}); await expect(mono.remove([])).resolves.toBeUndefined(); expect(updater).not.toHaveBeenCalled(); + expect(compare).not.toHaveBeenCalled(); expect(getSpy).not.toHaveBeenCalled(); expect(setSpy).not.toHaveBeenCalled(); expect(removeSpy).not.toHaveBeenCalled(); @@ -313,6 +334,19 @@ describe("batch overloads", () => { expect(setSpy).toHaveBeenCalledTimes(1); }); + + test("single update comparer returns the stored value when it skips a write", async () => { + const mono = new MonoStorage(key, base); + await mono.set("a", 1); + const setSpy = chrome.storage.local.set as jest.Mock; + setSpy.mockClear(); + + const result = await mono.update("a", () => 2, {compare: () => true}); + + expect(result).toBe(1); + expect(setSpy).not.toHaveBeenCalled(); + await expect(mono.get("a")).resolves.toBe(1); + }); }); test("update serializes concurrent bucket mutations", async () => { diff --git a/src/providers/MonoStorage.ts b/src/providers/MonoStorage.ts index 6f65723..527a27f 100644 --- a/src/providers/MonoStorage.ts +++ b/src/providers/MonoStorage.ts @@ -32,7 +32,7 @@ import type { * Bucket updaters return the received `bucketValue` when nothing changed and a * freshly built bucket otherwise, so reference identity — not deep equality — * decides whether the underlying provider writes. This is what lets a custom - * per-field comparer force a physically identical write. + * comparer force a physically identical write. */ const isUnchangedBucket = (previousBucket: unknown, nextBucket: unknown): boolean => previousBucket === nextBucket; @@ -189,9 +189,9 @@ export default class MonoStorage { expect(removeSpy).not.toHaveBeenCalled(); }); + test("aggregate comparer receives decrypted snapshots and can skip the whole patch", async () => { + const storage = new SecureStorage({namespace: "auth"}); + await storage.set({accessToken: "old", refreshToken: "remove", attempts: 1}); + + const compare = jest.fn(() => true); + const encryptSpy = crypto.subtle.encrypt as jest.Mock; + const setSpy = chrome.storage.local.set as jest.Mock; + const removeSpy = chrome.storage.local.remove as jest.Mock; + encryptSpy.mockClear(); + setSpy.mockClear(); + removeSpy.mockClear(); + + const result = await storage.update( + ["accessToken", "refreshToken", "attempts"] as const, + () => ({accessToken: "new", refreshToken: undefined}), + {compare} + ); + + expect(compare).toHaveBeenCalledTimes(1); + expect(compare).toHaveBeenCalledWith( + {accessToken: "old", refreshToken: "remove", attempts: 1}, + {accessToken: "new", attempts: 1} + ); + expect(result).toEqual({accessToken: "old", refreshToken: "remove", attempts: 1}); + expect(encryptSpy).not.toHaveBeenCalled(); + expect(setSpy).not.toHaveBeenCalled(); + expect(removeSpy).not.toHaveBeenCalled(); + }); + + test("aggregate comparer can force every explicit value to be encrypted and written", async () => { + const storage = new SecureStorage({namespace: "auth"}); + await storage.set({accessToken: "same", attempts: 1}); + + const compare = jest.fn(() => false); + const encryptSpy = crypto.subtle.encrypt as jest.Mock; + const setSpy = chrome.storage.local.set as jest.Mock; + encryptSpy.mockClear(); + setSpy.mockClear(); + + const result = await storage.update( + ["accessToken", "attempts"] as const, + () => ({accessToken: "same", attempts: 2}), + {compare} + ); + + expect(compare).toHaveBeenCalledWith( + {accessToken: "same", attempts: 1}, + {accessToken: "same", attempts: 2} + ); + expect(result).toEqual({accessToken: "same", attempts: 2}); + expect(encryptSpy).toHaveBeenCalledTimes(2); + expect(setSpy).toHaveBeenCalledTimes(1); + expect(Object.keys(setSpy.mock.calls[0]?.[0])).toEqual(["secure:auth:accessToken", "secure:auth:attempts"]); + }); + test("batch update encrypts writes before removing deleted keys in a mixed patch", async () => { const storage = new SecureStorage({namespace: "auth"}); await storage.set({accessToken: "old", refreshToken: "remove"}); diff --git a/src/providers/Storage.test.ts b/src/providers/Storage.test.ts index b38dd89..36f4509 100644 --- a/src/providers/Storage.test.ts +++ b/src/providers/Storage.test.ts @@ -1,7 +1,7 @@ import MonoStorage from "./MonoStorage"; import Storage from "./Storage"; import {StoragePartialUpdateError} from "../errors"; -import type {StorageBatchUpdateOptions, StorageLocker} from "../types"; +import type {StorageLocker} from "../types"; import {captureUnhandledErrors, flushMacrotask} from "../../tests/helpers/async"; const hasArea = (name: keyof typeof chrome.storage) => { @@ -258,10 +258,11 @@ describe("update method - no-op writes", () => { const setSpy = chrome.storage.local.set as jest.Mock; setSpy.mockClear(); - await storage.update("settings", () => ({theme: "dark"}), { + const result = await storage.update("settings", () => ({theme: "dark"}), { compare: () => true, }); + expect(result).toEqual({theme: "light"}); expect(setSpy).not.toHaveBeenCalled(); expect(await storage.get("settings")).toEqual({theme: "light"}); }); @@ -415,11 +416,18 @@ describe("batch overloads", () => { test("missing prototype-like keys stay absent and batch updater snapshots do not inherit them", async () => { const isolatedStorage = new Storage(); - const compare = jest.fn(() => false); - const compareMap = Object.create(null) as NonNullable< - StorageBatchUpdateOptions["compare"] - >; - compareMap.toString = compare; + const compare = jest.fn((prev: Partial, next: Partial) => { + expect(Object.getPrototypeOf(prev)).toBeNull(); + expect(Object.getPrototypeOf(next)).toBeNull(); + expect(prev.constructor).toBeUndefined(); + expect(prev.toString).toBeUndefined(); + expect(prev.valueOf).toBeUndefined(); + expect(next.constructor).toBeUndefined(); + expect(next.toString).toBe("stored"); + expect(next.valueOf).toBeUndefined(); + + return false; + }); await expect(isolatedStorage.get("toString")).resolves.toBeUndefined(); @@ -439,10 +447,10 @@ describe("batch overloads", () => { return patch; }, - {compare: compareMap} + {compare} ); - expect(compare).toHaveBeenCalledWith(undefined, "stored"); + expect(compare).toHaveBeenCalledTimes(1); await expect(isolatedStorage.get("toString")).resolves.toBe("stored"); }); @@ -516,29 +524,6 @@ describe("batch overloads", () => { await expect(isolatedStorage.get(["a", "b"] as const)).resolves.toEqual({a: 1, b: 3}); }); - test("batch comparer maps ignore inherited prototype functions", async () => { - interface NamedKeys { - constructor?: string; - toString?: string; - valueOf?: string; - } - - const isolatedStorage = new Storage(); - await isolatedStorage.set({constructor: "old", toString: "old", valueOf: "old"}); - - await isolatedStorage.update( - ["constructor", "toString", "valueOf"] as const, - () => ({constructor: "new", toString: "new", valueOf: "new"}), - {compare: {}} - ); - - await expect(isolatedStorage.getAll()).resolves.toEqual({ - constructor: "new", - toString: "new", - valueOf: "new", - }); - }); - test("batch update rejects patch keys outside the requested set without writing", async () => { const isolatedStorage = new Storage(); await isolatedStorage.set({a: 1}); @@ -617,26 +602,70 @@ describe("batch overloads", () => { expect(removeSpy).not.toHaveBeenCalled(); }); - test("batch update applies per-key comparers", async () => { + test("aggregate comparer can skip the whole mixed patch and returns the stored snapshot", async () => { const isolatedStorage = new Storage(); await isolatedStorage.set({a: 1, b: 2}); - const compareA = jest.fn(() => true); - const compareB = jest.fn(() => false); + const compare = jest.fn(() => true); const setSpy = chrome.storage.local.set as jest.Mock; + const removeSpy = chrome.storage.local.remove as jest.Mock; setSpy.mockClear(); + removeSpy.mockClear(); const result = await isolatedStorage.update( ["a", "b"] as const, - () => ({a: 2, b: 2}), - {compare: {a: compareA, b: compareB}} + () => ({a: undefined, b: 3}), + {compare} ); - expect(compareA).toHaveBeenCalledWith(1, 2); - expect(compareB).toHaveBeenCalledWith(2, 2); + expect(compare).toHaveBeenCalledTimes(1); + expect(compare).toHaveBeenCalledWith({a: 1, b: 2}, {b: 3}); expect(result).toEqual({a: 1, b: 2}); + expect(setSpy).not.toHaveBeenCalled(); + expect(removeSpy).not.toHaveBeenCalled(); + }); + + test("aggregate comparer can force every explicit value to be written", async () => { + const isolatedStorage = new Storage(); + await isolatedStorage.set({a: 1, b: 2}); + + const compare = jest.fn(() => false); + const setSpy = chrome.storage.local.set as jest.Mock; + setSpy.mockClear(); + + const result = await isolatedStorage.update( + ["a", "b"] as const, + () => ({a: 1, b: 3}), + {compare} + ); + + expect(compare).toHaveBeenCalledTimes(1); + expect(compare).toHaveBeenCalledWith({a: 1, b: 2}, {a: 1, b: 3}); + expect(result).toEqual({a: 1, b: 3}); expect(setSpy).toHaveBeenCalledTimes(1); - expect(setSpy).toHaveBeenCalledWith({b: 2}, expect.any(Function)); + expect(setSpy).toHaveBeenCalledWith({a: 1, b: 3}, expect.any(Function)); + }); + + test("aggregate comparer errors before any native write", async () => { + const isolatedStorage = new Storage(); + await isolatedStorage.set({a: 1, b: 2}); + + const setSpy = chrome.storage.local.set as jest.Mock; + const removeSpy = chrome.storage.local.remove as jest.Mock; + setSpy.mockClear(); + removeSpy.mockClear(); + + await expect( + isolatedStorage.update(["a", "b"] as const, () => ({a: undefined, b: 3}), { + compare: () => { + throw new Error("compare failed"); + }, + }) + ).rejects.toThrow("compare failed"); + + expect(setSpy).not.toHaveBeenCalled(); + expect(removeSpy).not.toHaveBeenCalled(); + await expect(isolatedStorage.get(["a", "b"] as const)).resolves.toEqual({a: 1, b: 2}); }); test("batch update deduplicates and sorts lock keys while forwarding lock options", async () => { @@ -732,6 +761,7 @@ describe("batch overloads", () => { test("empty batch operations do not call native storage", async () => { const isolatedStorage = new Storage(); const updater = jest.fn(() => ({})); + const compare = jest.fn(() => false); const getSpy = chrome.storage.local.get as jest.Mock; const setSpy = chrome.storage.local.set as jest.Mock; const removeSpy = chrome.storage.local.remove as jest.Mock; @@ -741,9 +771,10 @@ describe("batch overloads", () => { await expect(isolatedStorage.get([])).resolves.toEqual({}); await expect(isolatedStorage.set({})).resolves.toBeUndefined(); - await expect(isolatedStorage.update([], updater)).resolves.toEqual({}); + await expect(isolatedStorage.update([], updater, {compare})).resolves.toEqual({}); expect(updater).not.toHaveBeenCalled(); + expect(compare).not.toHaveBeenCalled(); expect(getSpy).not.toHaveBeenCalled(); expect(setSpy).not.toHaveBeenCalled(); expect(removeSpy).not.toHaveBeenCalled(); diff --git a/src/types.ts b/src/types.ts index 8e7fe8c..700070b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -40,12 +40,24 @@ export type StorageBatchUpdater> ) => Partial> | Promise>>; +export type StorageBatchSnapshot = Readonly< + Partial> +>; + +/** + * Equality check for a batch `update()`. Return `true` to treat the selected snapshot as equal and skip the update. + */ +export type StorageBatchUpdateComparer = ( + prev: StorageBatchSnapshot, + next: StorageBatchSnapshot +) => boolean; + export interface StorageBatchUpdateOptions extends StorageLockOptions { /** - * Per-key equality checks. Return `true` to skip the physical writing for that key. + * Equality check for the selected snapshot. Return `true` to skip the batch update. */ - compare?: Partial<{[P in K]: StorageUpdateComparer}>; + compare?: StorageBatchUpdateComparer; } export type StorageWatchCallback = ( diff --git a/tests/batch.types.ts b/tests/batch.types.ts index 8f72af2..da365ff 100644 --- a/tests/batch.types.ts +++ b/tests/batch.types.ts @@ -2,6 +2,8 @@ import type { MonoStorage, SecureStorage, Storage, + StorageBatchSnapshot, + StorageBatchUpdateComparer, StorageBatchUpdateOptions, StorageBatchUpdater, StorageChanges, @@ -57,9 +59,18 @@ async function verifyOverloadTypes() { return {count: (prev.count ?? 0) + 1, enabled: prev.enabled ?? true}; }, { - compare: { - count: (prev, next) => prev === next, - enabled: (prev, next) => prev === next, + compare: (prev, next) => { + type BatchComparePrev = Expect< + Equal>>> + >; + type BatchCompareNext = Expect< + Equal>>> + >; + + // @ts-expect-error aggregate comparer snapshots are readonly + prev.count = 1; + + return prev.count === next.count && prev.enabled === next.enabled; }, } ); @@ -96,12 +107,28 @@ async function verifyOverloadTypes() { const batchUpdater: StorageBatchUpdater = prev => ({ count: prev.count, }); + type BatchSnapshot = Expect< + Equal< + StorageBatchSnapshot, + Readonly>> + > + >; + const batchComparer: StorageBatchUpdateComparer = (prev, next) => + prev.count === next.count && prev.enabled === next.enabled; const batchOptions: StorageBatchUpdateOptions = { + compare: batchComparer, + }; + await storage.update(["count", "enabled"] as const, batchUpdater, batchOptions); + + void (null as unknown as BatchSnapshot); + + const invalidBatchOptions: StorageBatchUpdateOptions = { compare: { - count: (prev, next) => prev === next, + // @ts-expect-error batch compare is one aggregate function, not a per-key comparer map + count: (prev: number | undefined, next: number | undefined) => prev === next, }, }; - await storage.update(["count", "enabled"] as const, batchUpdater, batchOptions); + void invalidBatchOptions; // @ts-expect-error unknown single storage key await storage.get("unknown"); diff --git a/tests/default-state.types.ts b/tests/default-state.types.ts index ece7047..6568c9e 100644 --- a/tests/default-state.types.ts +++ b/tests/default-state.types.ts @@ -3,6 +3,8 @@ import { SecureStorage, Storage, StoragePartialUpdateError, + type StorageBatchSnapshot, + type StorageBatchUpdateComparer, type StorageBatchUpdateOptions, type StorageBatchUpdater, type StorageChanges, @@ -59,6 +61,8 @@ declare const monoStorageTypeWithoutGeneric: MonoStorage; declare const changesTypeWithoutGeneric: StorageChanges; declare const subscriberTypeWithoutGeneric: StorageSubscriber; declare const watcherTypeWithoutGeneric: StorageWatchOptions; +declare const batchSnapshotTypeWithoutGeneric: StorageBatchSnapshot; +declare const batchComparerTypeWithoutGeneric: StorageBatchUpdateComparer; declare const batchUpdaterTypeWithoutGeneric: StorageBatchUpdater; declare const batchOptionsTypeWithoutGeneric: StorageBatchUpdateOptions; declare const partialUpdateErrorTypeWithoutGeneric: StoragePartialUpdateError; @@ -70,6 +74,8 @@ void monoStorageTypeWithoutGeneric; void changesTypeWithoutGeneric; void subscriberTypeWithoutGeneric; void watcherTypeWithoutGeneric; +void batchSnapshotTypeWithoutGeneric; +void batchComparerTypeWithoutGeneric; void batchUpdaterTypeWithoutGeneric; void batchOptionsTypeWithoutGeneric; void partialUpdateErrorTypeWithoutGeneric; From f7f841ba78f495aaa97071339834c096d5059f5c Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Sat, 18 Jul 2026 11:33:04 +0300 Subject: [PATCH 7/8] docs: restructure README around functional API - make functional helpers and storage-area selection the primary onboarding path - consolidate class, batch, locking, watch, secure, and MonoStorage documentation - clarify provider scope, recovery behavior, public errors, and runtime limits --- README.md | 1062 +++++++++++++++++++++++++++++------------------------ 1 file changed, 587 insertions(+), 475 deletions(-) diff --git a/README.md b/README.md index da4ec0e..f1248bd 100644 --- a/README.md +++ b/README.md @@ -1,672 +1,775 @@ # @addon-core/storage -Typed storage for browser extensions with namespaces, lock-coordinated 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 - -- Typed single-key and batch overloads for `get`, `set`, and `update` -- Optional state contracts for both quick experimentation and strict typing -- Laravel-style functional helpers for provider creation and one-shot reads/writes -- Lock-coordinated `update()` for race-safe single-key and batch writes -- Per-key and per-event subscriptions through `watch()` and `subscribe()` -- `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", +}); -const token = await storage.get("token"); -const all = await storage.getAll(); +await settings.set({ + language: "uk", + theme: "dark", +}); + +const values = await settings.get(["theme", "language"] as const); +// Partial> ``` -## Start without a state contract +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()` | -A state interface is optional. Omit the generic when experimenting or when the -stored shape is intentionally dynamic: +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"; +import {storage} from "@addon-core/storage"; -const storage = Storage.Local({namespace: "playground"}); +const settings = storage({ + area: "sync", + namespace: "settings", +}); +``` -await storage.set("theme", "dark"); -await storage.set("attempts", 3); -await storage.set("profile", {name: "Ada"}); +`storageSecure()` also accepts `area` because one secure helper covers all +storage areas. -const theme = await storage.get("theme"); // any +## 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: + +```ts +storageLocal(); // default StorageProvider +storageLocal({namespace: "settings"}); // configured StorageProvider +storageLocal("theme"); // single get +storageLocal("theme", "dark"); // single set +storageLocal(["theme", "language"]); // batch get ``` -This is a deliberately loose TypeScript mode: keys and values are not tied to a -compile-time schema. It does not relax the package's runtime validation—top-level -`undefined` is still rejected, invalid keys still throw, and the browser's -serialization rules still apply. Add a state interface when the shape becomes -stable and key/value mistakes should be caught by TypeScript. +An object argument always means provider options. It is not interpreted as a +batch set. Create a provider for object-form writes: -## Functional helpers +```ts +const local = storageLocal(); -Use the functional API when a class factory is more ceremony than the operation -needs: +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: ```ts -import { - storage, - storageLocal, - storageManaged, - storageSecure, - storageSession, - storageSync, -} from "@addon-core/storage"; +const theme = await storageLocal<"light" | "dark">("theme"); -await storageLocal("draft", "Hello"); +await storageLocal("attempts", 3); +``` -const draft = await storageLocal("draft"); -// string | undefined +For a batch get, it describes the result map: +```ts const selected = await storageSync<{ language?: string; theme?: "light" | "dark"; }>(["theme", "language"]); -// Partial<{language?: string; theme?: "light" | "dark"}> ``` -With one string argument the generic describes the expected value. With a key -array it describes the returned map. Without a generic, one-shot operations use -the loose default state and values are `any`. +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. -Calling a helper without a key returns a real provider rather than a proxy or a -callable facade: +### 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 -interface UserSettings { - attempts?: number; - theme?: "light" | "dark"; -} +const popup = storageLocal({ + key: "popup", + namespace: "ui", +}); +``` -const settings = storageSync({namespace: "settings"}); +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. -await settings.set("theme", "dark"); -await settings.update("attempts", value => (value ?? 0) + 1); +## 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 local = Storage.Local(); +const session = Storage.Session(); +const sync = Storage.Sync({namespace: "settings"}); +const managed = Storage.Managed(); ``` -`storage()` selects local storage by default and accepts an explicit `area`. -`storageLocal()`, `storageSession()`, `storageSync()`, and `storageManaged()` -select a fixed area and therefore do not accept an `area` option. +`Storage.make()` accepts an explicit `area`, and the constructor is available for +direct composition: -Secure helpers use one general function to avoid multiplying area-specific -exports: +```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 = storageSecure({ - area: "session", +const auth = SecureStorage.Session({ namespace: "auth", secureKey: "AppSecret", }); - -await auth.set("accessToken", "jwt-token"); ``` -Pass `{key}` to any provider-form helper to create `MonoStorage`. A plain object -argument is always interpreted as helper options, so direct batch set is not a -helper overload. Create a provider and use its regular method instead: +`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 -const local = storageLocal(); +interface PopupState { + search?: string; + selectedTab?: "overview" | "history"; +} -await local.set({ - attempts: 1, - theme: "dark", +const popup = Storage.Local({ + key: "popup", }); ``` -Every helper invocation creates a new provider. Keep the returned provider when -performing a series of operations, especially with namespaces, custom lockers, -or secure storage. In particular, every one-shot `storageSecure()` call creates -a new provider and repeats the SHA-256 digest and AES key import. Reuse one secure -provider for a series of encrypted operations. Top-level `undefined` remains -invalid for set operations, and managed storage retains its browser-enforced -read-only behavior. +The `MonoStorage` class is also exported for custom provider composition, but the +`key` factory option is the concise path for normal use. -## Typed storage without boilerplate +## Reading and writing -Define your storage shape once: +Every provider exposes one consistent API regardless of its area or physical +storage model. + +### Read values ```ts -interface UserSettings { - theme?: "light" | "dark"; - language?: "en" | "uk"; - shortcutsEnabled?: boolean; -} +const theme = await settings.get("theme"); +const selected = await settings.get(["theme", "language"] as const); +const all = await settings.getAll(); ``` -Create a typed storage instance for the `sync` area: +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 -import {Storage} from "@addon-core/storage"; +await settings.set("theme", "dark"); -const settings = Storage.Sync({namespace: "settings"}); +await settings.set({ + language: "uk", + theme: "dark", +}); ``` -Now all operations are typed: +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.set("theme", "dark"); -const theme = await settings.get("theme"); await settings.remove("language"); +await settings.remove(["language", "theme"]); +await settings.clear(); ``` -## Batch operations +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). -Pass an array of keys or an object of values to the regular `get`, `set`, and -`update` methods when several values should be handled together. The overloads -preserve the same key and value types as their single-key forms. +## Safe updates -Read selected keys with one native storage request: +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. + +### Single-key update ```ts -const values = await settings.get(["theme", "language"] as const); +interface UsageState { + installCount?: number; +} -console.log(values.theme); -console.log(values.language); +const usage = storageLocal({ + namespace: "usage", +}); + +const count = await usage.update( + "installCount", + previous => (previous ?? 0) + 1 +); ``` -Missing keys are omitted from the returned object. An empty key list returns an -empty object. +The updater may be synchronous or asynchronous. Returning `undefined` removes the +key. By default, deeply equal results skip the physical write. + +### Batch update -Write several values with one native `storage.set()` call: +The array overload locks the selected keys in a stable order, reads one snapshot, +and applies one returned patch: ```ts -await settings.set({ - theme: "dark", - language: "uk", - shortcutsEnabled: true, -}); +const next = await settings.update( + ["theme", "language"] as const, + previous => ({ + language: previous.language ?? "en", + theme: previous.theme === "dark" ? "light" : "dark", + }) +); ``` -Both forms of `set()` reject `undefined` before encryption, locking, or native -I/O. Use `remove(keys)` or an `update()` patch when keys should be deleted. The -object form must be a plain object; arrays, class instances, and other values are -rejected instead of being interpreted as key maps. +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. -`set()` means "perform this write" and does not elide deeply equal values. -`update()` is the conditional API: its comparer decides whether a physical write -is needed. `watch()` and `subscribe()` still report only logical value changes. +### Equality comparison -When values change, this single native write produces one `storage.onChanged` -event containing the changed keys. Use `subscribe()` when the package-level -subscriber should also run once for that event. +A single-key comparer receives the previous and proposed values: -### Lock-coordinated batch updates +```ts +await settings.update( + "theme", + () => "dark", + { + compare: (previous, next) => previous === next, + } +); +``` -The array overload of `update()` locks the selected keys, reads one snapshot, -and applies the returned patch without races against other lock-aware package -operations: +A batch comparer receives the complete previous and proposed snapshots and makes +one decision for the whole batch: ```ts -const next = await settings.update( - ["theme", "language", "shortcutsEnabled"] as const, - prev => ({ - theme: prev.theme === "dark" ? "light" : "dark", - language: prev.language ?? "en", - }) +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"), + } ); - -console.log(next.theme); ``` -The updater may be synchronous or asynchronous. A selected key omitted from the -patch stays unchanged. An own property whose value is `undefined` removes that -key. Returning a key that was not included in the key list throws before any -write is made. The returned object is the final snapshot of the selected keys; -removed keys are omitted. +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. -Lock acquisition and one aggregate equality check can be configured for the -whole batch: +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. + +### Lock timeout and cancellation ```ts const controller = new AbortController(); -const result = await settings.update( +await settings.update( ["theme", "language"] as const, - prev => ({ + previous => ({ + ...previous, theme: "dark", - language: prev.language ?? "en", }), { signal: controller.signal, timeout: 500, - compare: (prev, next) => - prev.theme === next.theme && - (prev.language ?? "en") === (next.language ?? "en"), } ); ``` -The batch comparer receives two complete snapshots of the selected keys. The -first is the stored snapshot passed to the updater. The second is the proposed -snapshot after merging the updater's patch: omitted keys stay unchanged and own -properties set to `undefined` are removed. - -Returning `true` skips the whole batch. Nothing is written or removed, and -`update()` resolves to the previous stored snapshot. Returning `false` applies -the complete explicit patch, including defined values that are deeply equal to -their stored values, and resolves to the resulting snapshot. The comparer makes -one decision for the batch; it is not a per-key filter. Select which keys to -change by including only those keys in the updater's patch. - -Without a custom batch comparer, each explicit patch value uses deep equality. -Only changed values are written, only existing keys requested for deletion are -removed, and equal or omitted keys cause no native write. This default keeps the -physical update minimal. - -The `timeout` applies to each selected lock acquisition, not as one deadline -for the whole batch. Direct `set()` calls and raw native storage writes do not -participate in these locks. - -For regular `Storage` and `SecureStorage`, a patch that both writes values and -deletes keys requires one native `set()` followed by one native `remove()`. -That mixed operation therefore emits two native change events. If one event is -required, keep writes and deletions out of the same patch, or use `MonoStorage`, -where the logical values share one physical bucket. Consequently, -`subscribe()` can run twice for a mixed regular or secure update, while the -same `MonoStorage` update changes its bucket once and produces one callback. - -This two-phase operation is not a transaction. If `set()` completes and the -following `remove()` fails, the promise rejects with -`StoragePartialUpdateError`. Its `appliedSetKeys` and `attemptedRemoveKeys` -arrays contain logical keys, while `cause` contains the native removal error. -Only regular `Storage` and `SecureStorage` can throw this error; `MonoStorage` -commits the logical batch through one physical bucket operation. No rollback is -attempted. Extension context termination between the two native calls can leave -the same torn state without an observable JavaScript exception. - -## Lock-coordinated updates - -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. Lock-coordinated updates keep each -read-modify-write operation consistent with other package operations that use -the same locks, so one context does not overwrite changes made by another. +`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. + +### Mixed writes and deletions + +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. + +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. + +### Custom locker + +Providers use `WebLockManager` by default. Supply a `StorageLocker` when another +coordination mechanism is required: ```ts -interface CounterState { - installCount?: number; -} +import {storageLocal, type StorageLocker} from "@addon-core/storage"; -const storage = Storage.Local(); +const locker: StorageLocker = { + async request(name, task) { + return await task(); + }, +}; -await storage.update("installCount", prev => (prev ?? 0) + 1); +const storage = storageLocal<{count?: number}>({ + locker, + namespace: "custom-locking", +}); ``` -Use it for extension state that can be touched from more than one context: +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. -- 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. +## Watching changes -### With timeout or abort signal +### Watch individual keys + +`watch()` is key-oriented. A function watcher runs once for every changed +logical key: ```ts -const controller = new AbortController(); +const unsubscribe = settings.watch((next, previous, key) => { + console.log(key, previous, "->", next); +}); +``` -await storage.update( - "installCount", - prev => (prev ?? 0) + 1, - { - signal: controller.signal, - timeout: 500, - } -); +An object watcher handles only selected keys: + +```ts +const unsubscribe = settings.watch({ + theme(next, previous) { + console.log("theme", previous, "->", next); + }, + language(next, previous) { + console.log("language", previous, "->", next); + }, +}); ``` -### Custom compare +### Subscribe to complete events -`update()` skips writes when the value returned by the updater is equal to the -stored value. Pass `compare` when a specific update needs custom equality rules. -The comparer receives the stored previous value and the next value produced by -the updater. +`subscribe()` receives one logical change map for each matching package-level +event: ```ts -await storage.update( - "settings", - prev => ({...prev, theme: "dark"}), - { - compare: (prev, next) => prev?.version === next?.version, +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 + ); + } +}); ``` -If `compare` returns `true`, no write is made and `update()` resolves to the -previous stored value, not the value proposed by the updater. This also means no -`watch()` callbacks are triggered for that update. If it returns `false`, the -next value is written and returned. Use `compare: () => false` when you need to -force a physical write. It cannot force a logical notification: -`storage.onChanged` contains no provenance for the write, and equal logical -values are filtered by observers. +| Method | Delivery | +| --- | --- | +| `watch()` | One callback per changed logical key | +| `subscribe()` | One callback with the event’s logical change map | -### Important note +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. -Lock-coordinated operations rely on the Web Locks API. +### Delivery order and unsubscribe -- both overloads of `update()` use locking for safe writes; -- `Storage` and `SecureStorage` acquire selected key locks in a stable order, - while `MonoStorage` locks its single physical bucket; -- `remove()` and `clear()` are lock-aware too; -- reads work without Web Locks; -- direct `Storage` and `SecureStorage` writes through either `set()` overload do - not require Web Locks, while `MonoStorage` locks these writes because changing - a logical field is a read-modify-write of its bucket; -- if Web Locks are unavailable, lock-coordinated operations will throw. +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. -`signal` and `timeout` apply only while a lock request is queued. Once the lock -has been granted, aborting the signal does not cancel the updater. +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. -## Storage areas +### Error behavior -```ts -import {Storage} from "@addon-core/storage"; +Handle expected application failures inside the callback: -const local = Storage.Local<{draft?: string}>(); -const session = Storage.Session<{popupOpen?: boolean}>(); -const sync = Storage.Sync<{theme?: string}>(); -const managed = Storage.Managed<{policyEnabled?: boolean}>(); +```ts +const unsubscribe = settings.subscribe(async changes => { + try { + await sendChangesToServer(changes); + } catch (error) { + console.error("Could not synchronize storage changes", error); + } +}); ``` -The `managed` area is read-only. Both `get()` overloads and `getAll()` can read -managed policy values. A mutation rejects when it reaches a native managed-area -write. A package-level no-op may still resolve because no native write is made; -examples include `set({})`, `update([])`, `remove([])`, and an `update()` whose -result compares equal to the current value. Do not interpret a resolved no-op as -write access to managed storage. +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 +
-Use namespaces when different modules may use the same key names. +## Namespaces and data scope + +Use namespaces when modules may use the same logical key names: ```ts -const auth = Storage.Local<{token?: string}>({namespace: "auth"}); -const ui = Storage.Local<{token?: string}>({namespace: "ui"}); +const auth = storageLocal<{token?: string}>({ + namespace: "auth", +}); + +const analytics = storageLocal<{token?: string}>({ + namespace: "analytics", +}); ``` -These storage instances stay isolated even if the key name is the same. +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. -The colon (`:`) is reserved as the separator in physical storage keys. -Namespaces and top-level logical keys used by `Storage` or `SecureStorage` -therefore cannot contain `:`. The physical bucket key passed as `{key}` to a -package factory follows the same rule, while logical field names inside a -`MonoStorage` bucket may contain `:`. Entries previously written with a colon in -a restricted component are not migrated or removed automatically; clean them up -by their exact physical key through the native `chrome.storage.` API before -using the provider. +Keys that do not match a provider’s exact codec are ignored by its `getAll()`, +events, and `clear()`. -## Secure storage +## SecureStorage -`SecureStorage` encrypts values before writing them to `chrome.storage`. +`SecureStorage` encrypts each logical value with AES-GCM before writing it to +native storage. + +The recommended functional form is: ```ts -import {SecureStorage} from "@addon-core/storage"; +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 authStorage.set("accessToken", "jwt-token"); -const token = await authStorage.get("accessToken"); +await auth.set("accessToken", "jwt-token"); +const token = await auth.get("accessToken"); ``` -Use it for tokens, sensitive flags, or other small private values. +The class factories provide the same behavior: -Secure physical keys always have three segments: +```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: ```text secure:: ``` -For example, an unnamespaced `theme` key is stored as `secure::theme`, while an -`accessToken` in the `auth` namespace is stored as `secure:auth:accessToken`. -This fixed shape keeps unnamespaced secure data separate from plain -`Storage({namespace: "secure"})`, whose corresponding key is `secure:theme`. +Examples: + +```text +secure::theme +secure:auth:accessToken +``` -Older namespaced SecureStorage keys already use the current shape and require no -migration. Older unnamespaced keys used `secure:key` and are not read, migrated, -or removed automatically. Migrate only explicitly known legacy SecureStorage -entries through the matching native storage area: +This distinguishes unnamespaced secure data from plain +`storageLocal({namespace: "secure"})` data such as `secure:theme`. The +ciphertext format remains `iv:ciphertext`. -```ts -const legacyKey = "secure:theme"; -const currentKey = "secure::theme"; -const values = await chrome.storage.local.get([legacyKey, currentKey]); +### Corrupted values and recovery -if (Object.prototype.hasOwnProperty.call(values, currentKey)) { - throw new Error(`Refusing to overwrite ${currentKey}`); -} +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. -if (Object.prototype.hasOwnProperty.call(values, legacyKey)) { - await chrome.storage.local.set({[currentKey]: values[legacyKey]}); - await chrome.storage.local.remove(legacyKey); -} -``` +One corrupted selected key rejects the whole batch get. One corrupted owned key +rejects `getAll()`. Reads do not silently fall back to defaults. -The ciphertext can be copied without decryption because the physical key is not -used as AES-GCM additional authenticated data. Do not migrate `secure:key` by -pattern alone: the same legacy key may belong to plain -`Storage({namespace: "secure"})`. +Known corrupted SecureStorage entries can be recovered without decrypting the +old value: -`SecureStorage` treats a present empty, non-string, or undecipherable value as -corruption and throws the exported `StorageCorruptionError`. Its `provider` and -`key` identify the failed logical entry, and `cause` preserves the format or -decryption error. A corrupted key rejects `get()`, a selected-key batch `get()`, -and the whole `getAll()` result; reads do not silently fall back to defaults. -This can intentionally fail an extension boot path that depends on `getAll()`. +- `set()` replaces a known key; +- `remove()` deletes known keys; +- `clear()` enumerates and removes all keys owned by that secure provider. -Direct `SecureStorage` operations can recover known corrupted data without -decrypting it: overwrite the key with `set()`, delete a known key with `remove()`, -or delete the provider contents with `clear()`. `remove()` and `clear()` never -decrypt stored ciphertext. `getAll()`, events, and `clear()` only consider keys -with the exact current physical shape; malformed and legacy keys require raw -cleanup. +`remove()` and `clear()` never decrypt the stored ciphertext. ## MonoStorage -`MonoStorage` is useful when one feature should live under a single top-level storage key. - -For example, keeping popup state together: +`MonoStorage` groups a feature’s logical state under one physical storage key. ```ts -import {Storage} from "@addon-core/storage"; - interface PopupState { + filters?: string[]; search?: string; selectedTab?: "overview" | "history"; - filters?: string[]; } -const popup = Storage.Local({key: "popup"}); -``` - -Then use it like a regular storage instance: +const popup = storageLocal({ + key: "popup", +}); -```ts await popup.set({ search: "open tabs", selectedTab: "overview", }); -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()` -performs one locked bucket update and writes even when the supplied logical value -is deeply equal. Its batch writes and updates also change that bucket only once. -A present non-plain-object bucket is treated as corrupted rather than as an empty -bucket. `clear()` can still remove it without decoding it. +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. + +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. ### Corruption recovery -Recovery differs because `SecureStorage` stores keys independently, while -`MonoStorage` must decode its complete bucket before changing one logical field: +Recovery differs because SecureStorage stores values independently while +MonoStorage must decode its complete bucket before changing one logical field. | Provider | `set()` | `remove()` | `clear()` | | --- | --- | --- | --- | -| `SecureStorage` | Recovers a known key by replacing its ciphertext without reading the old value. | Recovers known keys by removing their physical entries without decrypting them. | Enumerates matching physical keys and removes them without decrypting their values. | -| `MonoStorage` over `Storage` | Cannot recover a corrupted bucket because changing one field first reads and decodes the bucket. | Cannot remove a logical field from a corrupted bucket for the same reason. | Recovers by removing the single physical bucket directly. | -| `MonoStorage` over `SecureStorage` | Cannot recover a corrupted ciphertext or decoded bucket because changing one field first decrypts and decodes the bucket. | Cannot remove a logical field from a corrupted bucket for the same reason. | Recovers by removing the encrypted physical bucket without decrypting it. | - -For a corrupted `MonoStorage` bucket, use `clear()` or remove/replace its exact -physical bucket through the underlying provider or native storage API. Ordinary -logical `set()`, `update()`, and `remove()` calls intentionally fail instead of -coercing damaged data into a new bucket. - -## Watching changes - -Listen to all keys: - -```ts -const unsubscribe = settings.watch((next, prev, key) => { - console.log("changed", key, {prev, next}); -}); -``` - -Or watch only specific keys: - -```ts -const unsubscribe = settings.watch({ - theme(next, prev) { - console.log("theme changed", prev, "->", next); - }, - language(next, prev) { - console.log("language changed", prev, "->", next); - }, -}); -``` - -`watch()` is key-oriented: when one native storage event contains several keys, -its global callback runs once for each changed logical key. Use `subscribe()` to -handle the same event as one typed changes map: +| `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.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); - } -}); -``` - -`subscribe()` handles each matching native event after namespace filtering and -deep-equality filtering. `SecureStorage` decrypts the values first, and -`MonoStorage` expands its physical bucket change into logical key changes. The -subscriber runs once with the remaining logical changes, or is not called when -every entry is unchanged. Each entry contains the logical key's `oldValue` and -`newValue`; this also normalizes Firefox events that may include unchanged keys -passed to `set()`. - -Events are formatted in FIFO order for each registration, so the callback for an -earlier event is invoked before the callback for a later event. Returned callback -promises are observed for rejection but are not awaited; asynchronous callbacks -may overlap and cannot block later storage events. - -Calling the returned unsubscribe function immediately removes the native -listener, clears queued events, and prevents delivery after an in-progress -format/decrypt step finishes. - -Corruption or another internal formatting failure disposes that registration and -is surfaced as an uncaught asynchronous exception. There is no `onError` option, -and a `try/catch` around `watch()` or `subscribe()` cannot catch an error produced -by a later native event. User callback throws and promise rejections are also -surfaced as uncaught asynchronous exceptions, but they do not dispose the -registration; other key handlers and later events continue to run. - -For `SecureStorage`, one corrupted matching entry in a multi-key native event -rejects the whole logical event: valid sibling changes from the same provider -scope are not delivered partially, the registration is disposed, and its queued -events are discarded. This includes an unwatched sibling key in the same area -and namespace; entries from another namespace are filtered out first. The -failure is scoped to that `watch()` or `subscribe()` registration; separately -registered listeners process matching entries independently and can fail in the -same way. - -In a persistent MV2 background page, an internally failed registration remains -disposed until the page reloads. An MV3 service worker registers it again after a -later wake-up, so repeating corrupted input can produce a visible crash loop -instead of a permanently silent listener. +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 (