Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 31 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ await settings.remove("language");
## Atomic 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.

```ts
interface CounterState {
Expand All @@ -109,13 +112,13 @@ const storage = Storage.Local<CounterState>();
await storage.update("installCount", prev => (prev ?? 0) + 1);
```

This is useful for:
Use it for extension state that can be touched from more than one context:

- counters;
- retry state;
- toggles;
- queue metadata;
- any concurrent read-modify-write flow.
- 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.

### With timeout or abort signal

Expand All @@ -132,6 +135,25 @@ 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.

```ts
await storage.update(
"settings",
prev => ({...prev, theme: "dark"}),
{
compare: (prev, next) => prev?.version === next?.version,
}
);
```

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.

### Important note

Atomic operations rely on the Web Locks API.
Expand Down Expand Up @@ -213,7 +235,9 @@ await popup.update("filters", prev => [...(prev ?? []), "pinned"]);
const state = await popup.getAll();
```

This keeps related values grouped and easier to manage.
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.

## Watching changes

Expand Down
16 changes: 14 additions & 2 deletions src/providers/AbstractStorage.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import {browser} from "@addon-core/browser";
import {callWithPromise, handleListener} from "@addon-core/browser/utils";
import {dequal as defaultCompare} from "dequal/lite";
import LockManager from "../LockManager";
import MonoStorage from "./MonoStorage";
import type {
StorageLocker,
StorageLockOptions,
StorageProvider,
StorageState,
StorageUpdateOptions,
StorageUpdater,
StorageWatchOptions,
} from "../types";
Expand Down Expand Up @@ -152,24 +154,34 @@ export default abstract class AbstractStorage<T extends StorageState> implements
public async update<K extends keyof T>(
key: K,
updater: StorageUpdater<T[K]>,
options?: StorageLockOptions
options?: StorageUpdateOptions<T[K]>
): Promise<T[K] | undefined> {
const {compare = defaultCompare, ...lockOptions} = options ?? {};

return await this.locker.request(
this.getLockKey(key),
async () => {
const prev = await this.getUnlocked(key);
const next = await updater(prev);

if (next === undefined) {
if (prev === undefined) {
return undefined;
}

await this.removeUnlocked(key);
return undefined;
}

if (compare(prev, next)) {
return next;
}

await this.setUnlocked(key, next);

return next;
},
options
lockOptions
);
}

Expand Down
42 changes: 42 additions & 0 deletions src/providers/MonoStorage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,48 @@ test("update serializes concurrent bucket mutations", async () => {
expect(await mono.get("a")).toBe(2);
});

test("update skips physical bucket write when inner value is equal", async () => {
const mono = new MonoStorage<BucketState, typeof key>(key, base);
await mono.set("b", {x: 1});

const setSpy = chrome.storage.local.set as jest.Mock;
setSpy.mockClear();

await mono.update("b", prev => (prev && typeof prev === "object" ? {...prev} : {x: 1}));

expect(setSpy).not.toHaveBeenCalled();
expect(await mono.getAll()).toEqual({b: {x: 1}});
});

test("update skips physical bucket write when deleting an absent inner key", async () => {
const mono = new MonoStorage<BucketState, typeof key>(key, base);
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 mono.update("c", () => undefined);

expect(setSpy).not.toHaveBeenCalled();
expect(removeSpy).not.toHaveBeenCalled();
expect(await mono.getAll()).toEqual({a: 1});
});

test("update writes the physical bucket when inner value changes", async () => {
const mono = new MonoStorage<BucketState, typeof key>(key, base);
await mono.set("a", 1);

const setSpy = chrome.storage.local.set as jest.Mock;
setSpy.mockClear();

await mono.update("a", prev => (prev ?? 0) + 1);

expect(setSpy).toHaveBeenCalledTimes(1);
expect(await mono.getAll()).toEqual({a: 2});
});

test("getAll returns the whole bucket", async () => {
const mono = new MonoStorage<BucketState, typeof key>(key, base);
await mono.set("a", 1);
Expand Down
21 changes: 17 additions & 4 deletions src/providers/MonoStorage.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import {dequal as isEqual} from "dequal/lite";
import type {StorageLockOptions, StorageProvider, StorageState, StorageUpdater, StorageWatchOptions} from "../types";
import type {
StorageLockOptions,
StorageProvider,
StorageState,
StorageUpdateOptions,
StorageUpdater,
StorageWatchOptions,
} from "../types";

export default class MonoStorage<T extends StorageState, K extends string> implements StorageProvider<T> {
constructor(
Expand Down Expand Up @@ -30,8 +37,10 @@ export default class MonoStorage<T extends StorageState, K extends string> imple
public async update<KP extends keyof T>(
key: KP,
updater: StorageUpdater<T[KP]>,
options?: StorageLockOptions
options?: StorageUpdateOptions<T[KP]>
): Promise<T[KP] | undefined> {
const {compare = isEqual, ...lockOptions} = options ?? {};

const nextBucket = await this.storage.update(
this.key,
async bucketValue => {
Expand All @@ -41,7 +50,7 @@ export default class MonoStorage<T extends StorageState, K extends string> imple

if (nextValue === undefined) {
if (!(key in bucket)) {
return bucket;
return bucketValue;
}

const next = {...bucket};
Expand All @@ -50,9 +59,13 @@ export default class MonoStorage<T extends StorageState, K extends string> imple
return Object.keys(next).length === 0 ? undefined : next;
}

if (compare(prevValue, nextValue)) {
return bucketValue;
}

return {...bucket, [key]: nextValue};
},
options
lockOptions
);

return nextBucket?.[key] as T[KP] | undefined;
Expand Down
17 changes: 17 additions & 0 deletions src/providers/SecureStorage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,23 @@ describe("set method", () => {
});
});

describe("update method", () => {
test("skips encryption and storage write when decrypted value is equal", async () => {
await securedStorage.set("settings", {theme: "dark"});

const encryptSpy = crypto.subtle.encrypt as jest.Mock;
const setSpy = chrome.storage.local.set as jest.Mock;
encryptSpy.mockClear();
setSpy.mockClear();

await securedStorage.update("settings", prev => ({...prev}));

expect(encryptSpy).not.toHaveBeenCalled();
expect(setSpy).not.toHaveBeenCalled();
expect((await securedStorage.getAll())["settings"]).toEqual({theme: "dark"});
});
});

describe("remove method", () => {
test("deletes the key without namespace", async () => {
await securedStorage.set("theme", "dark");
Expand Down
88 changes: 87 additions & 1 deletion src/providers/Storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,13 @@ test("update method - forwards lock options to custom storage locker", async ()

const isolatedStorage = new Storage({locker});
const controller = new AbortController();
const compare = () => false;

await isolatedStorage.update("counter", prev => (prev ?? 0) + 1, {signal: controller.signal, timeout: 25});
await isolatedStorage.update("counter", prev => (prev ?? 0) + 1, {
signal: controller.signal,
timeout: 25,
compare,
});

expect(requests).toEqual([
{
Expand All @@ -154,6 +159,87 @@ test("update method - forwards lock options to custom storage locker", async ()
expect(await isolatedStorage.get("counter")).toBe(1);
});

describe("update method - no-op writes", () => {
test.each([
["primitive", "dark", () => "dark"],
["object", {theme: "dark"}, (prev: {theme: string} | undefined) => ({...prev})],
])("skips storage.set when the next %s value is equal", async (_, initialValue, updater) => {
await storage.set("settings", initialValue);

const setSpy = chrome.storage.local.set as jest.Mock;
setSpy.mockClear();

await storage.update("settings", updater as any);

expect(setSpy).not.toHaveBeenCalled();
expect(await storage.get("settings")).toEqual(initialValue);
});

test("writes when the next value changes", async () => {
await storage.set("settings", {theme: "light"});

const setSpy = chrome.storage.local.set as jest.Mock;
setSpy.mockClear();

await storage.update("settings", prev => ({...prev, theme: "dark"}));

expect(setSpy).toHaveBeenCalledTimes(1);
expect(await storage.get("settings")).toEqual({theme: "dark"});
});

test("writes when creating a missing value", async () => {
const setSpy = chrome.storage.local.set as jest.Mock;
setSpy.mockClear();

await storage.update("settings", () => ({theme: "dark"}));

expect(setSpy).toHaveBeenCalledTimes(1);
expect(await storage.get("settings")).toEqual({theme: "dark"});
});

test("skips storage.remove when deleting an already missing value", async () => {
const removeSpy = chrome.storage.local.remove as jest.Mock;
removeSpy.mockClear();

await storage.update("missing", () => undefined);

expect(removeSpy).not.toHaveBeenCalled();
});

test("custom compare can force a write for equal values", async () => {
const initialValue = {theme: "dark"};
const nextValue = {theme: "dark"};
const compare = jest.fn(() => false);

await storage.set("settings", initialValue);

const setSpy = chrome.storage.local.set as jest.Mock;
setSpy.mockClear();

await storage.update("settings", () => nextValue, {
compare,
});

expect(compare).toHaveBeenCalledWith(initialValue, nextValue);
expect(setSpy).toHaveBeenCalledTimes(1);
expect(await storage.get("settings")).toEqual({theme: "dark"});
});

test("custom compare can force a skip for unequal values", async () => {
await storage.set("settings", {theme: "light"});

const setSpy = chrome.storage.local.set as jest.Mock;
setSpy.mockClear();

await storage.update("settings", () => ({theme: "dark"}), {
compare: () => true,
});

expect(setSpy).not.toHaveBeenCalled();
expect(await storage.get("settings")).toEqual({theme: "light"});
});
});

test("getAll method - returns all values from current namespace", async () => {
await storage.set("a", 1);
await storage.set("b", 2);
Expand Down
17 changes: 16 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,21 @@ export interface StorageLocker {

export type StorageUpdater<T> = (prev: T | undefined) => T | undefined | Promise<T | undefined>;

/**
* Equality check for `update()`. Return `true` to treat values as equal and skip the write.
*
* The comparer is not called when the updater returns `undefined`.
* For `SecureStorage`, values are decrypted before comparison.
*/
export type StorageUpdateComparer<T> = (prev: T | undefined, next: T | undefined) => boolean;

export interface StorageUpdateOptions<T> extends StorageLockOptions {
/**
* Custom equality check for this update. Return `true` to skip the physical write.
*/
compare?: StorageUpdateComparer<T>;
}

export type StorageWatchCallback<T> = <K extends keyof T>(
newValue: T[K] | undefined,
oldValue: T[K] | undefined,
Expand All @@ -36,7 +51,7 @@ export interface StorageProvider<T extends StorageState> {
update<K extends keyof T>(
key: K,
updater: StorageUpdater<T[K]>,
options?: StorageLockOptions
options?: StorageUpdateOptions<T[K]>
): Promise<T[K] | undefined>;

get<K extends keyof T>(key: K): Promise<T[K] | undefined>;
Expand Down
Loading