From d1b9e5ded659ebbc5654c19c85d2dfb0937c5251 Mon Sep 17 00:00:00 2001 From: Aaron Date: Wed, 27 Dec 2023 11:00:10 -0600 Subject: [PATCH] feat!: New `wxt/storage` APIs (#300) BREAKING CHANGE: `wxt/storage` no longer relies on `unstorage`. Some `unstorage` APIs, like `prefixStorage`, have been removed, while others, like `snapshot`, are methods on the new `storage` object. Most of the standard usage remains the same. --- docs/guide/storage.md | 274 ++++++++++-- e2e/tests/auto-imports.test.ts | 10 - package.json | 2 +- pnpm-lock.yaml | 404 +----------------- src/__tests__/storage.test.ts | 739 ++++++++++++++++++++++++++++++++- src/storage.ts | 671 +++++++++++++++++++++++++----- 6 files changed, 1555 insertions(+), 545 deletions(-) diff --git a/docs/guide/storage.md b/docs/guide/storage.md index 65a0976a..55a17f95 100644 --- a/docs/guide/storage.md +++ b/docs/guide/storage.md @@ -1,36 +1,254 @@ # Storage API -WXT's storage API is powered by `unstorage`. See [their docs](https://unstorage.unjs.io/usage#usage-1) for more details. - -## Overview - -:::code-group - -```ts [native] -const { installDate } = await browser.storage.local.get('installDate'); -await browser.storage.local.set({ key: 'value' }); -``` - -```ts [wxt/browser] -const installDate = await storage.get('local:installDate'); -await storage.setItem('key', 'value'); -``` - -::: - -Use the `"local:"`, `"session:"`, `"sync:"`, and `"managed:"` prefixes to specify which storage area to use. - -## Customization - -WXT also provides a driver for `unstorage`. To customize the `storage` object's setup, like removing the prefixes and using a single storage area, you can create your own storage: +WXT provides a simplified API to replace the `browser.storage.*` APIs. Use the `storage` auto-import from `wxt/storage` or import it manually to get started: ```ts -// storage.ts -export default createStorage({ - driver: webExtensionDriver({ storageArea: 'local' }), +import { storage } from 'wxt/storage'; +``` + +[[toc]] + +## Basic Usage + +All storage keys must be prefixed by their storage area. + +```ts +// ❌ This will throw an error +await storage.getItem('installDate'); + +// ✅ This is good +await storage.getItem('local:installDate'); +``` + +You can use `local:`, `session:`, `sync:`, or `managed:`. + +If you use TypeScript, you can add a type parameter to most methods to specify the expected type of the key's value: + +```ts +await storage.getItem('local:installDate'); +await storage.watch( + 'local:installDate', + (newInstallDate, oldInstallDate) => { + // ... + }, +); +await storage.getMeta<{ v: number }>('local:installDate'); +``` + +## Watchers + +To listen for storage changes, use the `storage.watch` function. It lets you setup a listener for a single key: + +```ts +const unwatch = storage.watch('local:counter', (newCount, oldCount) => { + console.log('Count changed:', { newCount, oldCount }); }); ``` -:::note -`wxt/browser` re-exports all of `unstorage`, which is where `createStorage` comes from. +To remove the listener, call the returned `unwatch` function: + +```ts +const unwatch = storage.watch(...); + +// Some time later... +unwatch(); +``` + +## Metadata + +`wxt/storage` also supports setting metadata for keys, stored at `key + "$"`. Metadata is a collection of properties associated with a key. It might be a version number, last modified date, etc. + +[Other than versioning](#versioning-and-migrations), you are responsible for managing a field's metadata: + +```ts +await Promise.all([ + storage.setItem('local:preference', true), + storage.setMeta('local:preference', { lastModified: Date.now() }), +]); +``` + +When setting different properties of metadata from multiple calls, the properties are combined instead of overwritten: + +```ts +await storage.setMeta('local:preference', { lastModified: Date.now() }); +await storage.setMeta('local:preference', { v: 2 }); + +await storage.getMeta('local:preference'); // { v: 2, lastModified: 1703690746007 } +``` + +You can remove all metadata associated with a key, or just specific properties: + +```ts +// Remove all properties +await storage.removeMeta('local:preference'); + +// Remove one property +await storage.removeMeta('local:preference', 'lastModified'); + +// Remove multiple properties +await storage.removeMeta('local:preference', ['lastModified', 'v']); +``` + +## Defining Storage Items + +Writing the key and type parameter for the same key over and over again can be annoying. As an alternative, you can use `storage.defineItem` to create a "storage item". + +Storage items contain the same APIs as the `storage` variable, but you can configure its type, default value, and more in a single place: + +```ts +// utils/storage.ts +const showChangelogOnUpdate = storage.defineItem( + 'local:showChangelogOnUpdate', + { + defaultValue: true, + }, +); +``` + +Now, instead of using the `storage` variable, you can use the helper functions on the storage item you created: + +```ts +await showChangelogOnUpdate.getValue(); +await showChangelogOnUpdate.setValue(false); +await showChangelogOnUpdate.removeValue(); +const unwatch = showChangelogOnUpdate.watch(() => { + // ... +}); +``` + +### Versioning and Migrations + +You can add versioning to storage items if you expect them to grow or change over time. When defining the first version of an item, start with version 1. + +For example, consider a storage item that stores a list of websites that are ignored by an extension. + +:::code-group + +```ts [v1] +type IgnoredWebsiteV1 = string; + +export const ignoredWebsites = storage.defineItem( + 'local:ignoredWebsites', + { + defaultValue: [], + version: 1, + }, +); +``` + + +```ts [v2] +import { nanoid } from 'nanoid'; // [!code ++] + +type IgnoredWebsiteV1 = string; +interface IgnoredWebsiteV2 { // [!code ++] + id: string; // [!code ++] + website: string; // [!code ++] +} // [!code ++] + +export const ignoredWebsites = storage.defineItem( // [!code --] +export const ignoredWebsites = storage.defineItem( // [!code ++] + 'local:ignoredWebsites', + { + defaultValue: [], + version: 1, // [!code --] + version: 2, // [!code ++] + migrations: { // [!code ++] + // Ran when migrating from v1 to v2 // [!code ++] + 2: (websites: IgnoredWebsiteV1[]): IgnoredWebsiteV2[] => { // [!code ++] + return websites.map((website) => ({ id: nanoid(), website })); // [!code ++] + }, // [!code ++] + }, // [!code ++] + }, +); +``` + + +```ts [v3] +import { nanoid } from 'nanoid'; + +type IgnoredWebsiteV1 = string; +interface IgnoredWebsiteV2 { + id: string; + website: string; +} +interface IgnoredWebsiteV3 { // [!code ++] + id: string; // [!code ++] + website: string; // [!code ++] + enabled: boolean; // [!code ++] +} // [!code ++] + +export const ignoredWebsites = storage.defineItem( // [!code --] +export const ignoredWebsites = storage.defineItem( // [!code ++] + 'local:ignoredWebsites', + { + defaultValue: [], + version: 2, // [!code --] + version: 3, // [!code ++] + migrations: { + // Ran when migrating from v1 to v2 + 2: (websites: IgnoredWebsiteV1[]): IgnoredWebsiteV2[] => { + return websites.map((website) => ({ id: nanoid(), website })); + }, + // Ran when migrating from v2 to v3 // [!code ++] + 3: (websites: IgnoredWebsiteV2[]): IgnoredWebsiteV3[] => { // [!code ++] + return websites.map((website) => ({ ...website, enabled: true })); // [!code ++] + }, // [!code ++] + }, + }, +); +``` + +::: + +:::info +Internally, this uses a metadata property called `v` to track the value's current version. +::: + +In this case, we thought that the ignored website list might change in the future, and were able to setup a versioned storage item from the start. + +Realistically, you won't know a item needs versioned until you need to change it's schema. Thankfully, it's simple to add versioning to an unversioned storage item. + +When a previous version isn't found, WXT assumes the version was `1`. That means you just need to set `version: 2` and add a migration for `2`, and it will just work! + +Lets look at the same ignored websites example from before, but start with an unversioned item this time: + +:::code-group + +```ts [Unversioned] +export const ignoredWebsites = storage.defineItem( + 'local:ignoredWebsites', + { + defaultValue: [], + }, +); +``` + + +```ts [v2] +import { nanoid } from 'nanoid'; // [!code ++] + +// Retroactively add a type for the first version // [!code ++] +type IgnoredWebsiteV1 = string; // [!code ++] +interface IgnoredWebsiteV2 { // [!code ++] + id: string; // [!code ++] + website: string; // [!code ++] +} // [!code ++] + +export const ignoredWebsites = storage.defineItem( // [!code --] +export const ignoredWebsites = storage.defineItem( // [!code ++] + 'local:ignoredWebsites', + { + defaultValue: [], + version: 2, // [!code ++] + migrations: { // [!code ++] + // Ran when migrating from v1 to v2 // [!code ++] + 2: (websites: IgnoredWebsiteV1[]): IgnoredWebsiteV2[] => { // [!code ++] + return websites.map((website) => ({ id: nanoid(), website })); // [!code ++] + }, // [!code ++] + }, // [!code ++] + }, +); +``` + ::: diff --git a/e2e/tests/auto-imports.test.ts b/e2e/tests/auto-imports.test.ts index 62929fa8..578d9661 100644 --- a/e2e/tests/auto-imports.test.ts +++ b/e2e/tests/auto-imports.test.ts @@ -20,24 +20,14 @@ describe('Auto Imports', () => { const InvalidMatchPattern: typeof import('wxt/sandbox')['InvalidMatchPattern'] const MatchPattern: typeof import('wxt/sandbox')['MatchPattern'] const browser: typeof import('wxt/browser')['browser'] - const builtinDrivers: typeof import('wxt/storage')['builtinDrivers'] const createContentScriptIframe: typeof import('wxt/client')['createContentScriptIframe'] const createContentScriptUi: typeof import('wxt/client')['createContentScriptUi'] - const createStorage: typeof import('wxt/storage')['createStorage'] const defineBackground: typeof import('wxt/sandbox')['defineBackground'] const defineConfig: typeof import('wxt')['defineConfig'] const defineContentScript: typeof import('wxt/sandbox')['defineContentScript'] - const defineDriver: typeof import('wxt/storage')['defineDriver'] const defineUnlistedScript: typeof import('wxt/sandbox')['defineUnlistedScript'] const fakeBrowser: typeof import('wxt/testing')['fakeBrowser'] - const joinKeys: typeof import('wxt/storage')['joinKeys'] - const normalizeBaseKey: typeof import('wxt/storage')['normalizeBaseKey'] - const normalizeKey: typeof import('wxt/storage')['normalizeKey'] - const prefixStorage: typeof import('wxt/storage')['prefixStorage'] - const restoreSnapshot: typeof import('wxt/storage')['restoreSnapshot'] - const snapshot: typeof import('wxt/storage')['snapshot'] const storage: typeof import('wxt/storage')['storage'] - const webExtensionDriver: typeof import('wxt/storage')['webExtensionDriver'] } " `); diff --git a/package.json b/package.json index 53a461fa..368d6285 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,7 @@ "chokidar": "^3.5.3", "consola": "^3.2.3", "defu": "^6.1.3", + "dequal": "^2.0.3", "esbuild": "^0.19.5", "fast-glob": "^3.3.1", "filesize": "^10.0.8", @@ -128,7 +129,6 @@ "prompts": "^2.4.2", "rollup-plugin-visualizer": "^5.9.2", "unimport": "^3.4.0", - "unstorage": "^1.9.0", "vite": "^5.0.0", "web-ext-run": "^0.1.0", "webextension-polyfill": "^0.10.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f67c77ec..e8bd55e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: defu: specifier: ^6.1.3 version: 6.1.3 + dequal: + specifier: ^2.0.3 + version: 2.0.3 esbuild: specifier: ^0.19.5 version: 0.19.8 @@ -92,9 +95,6 @@ importers: unimport: specifier: ^3.4.0 version: 3.4.0 - unstorage: - specifier: ^1.9.0 - version: 1.9.0 vite: specifier: ^5.0.0 version: 5.0.5(@types/node@20.10.3)(sass@1.69.5) @@ -862,10 +862,6 @@ packages: engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0, npm: '>=6.14.13'} dev: true - /@ioredis/commands@1.2.0: - resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==} - dev: false - /@istanbuljs/schema@0.1.3: resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} engines: {node: '>=8'} @@ -928,148 +924,6 @@ packages: '@nodelib/fs.scandir': 2.1.5 fastq: 1.15.0 - /@parcel/watcher-android-arm64@2.3.0: - resolution: {integrity: sha512-f4o9eA3dgk0XRT3XhB0UWpWpLnKgrh1IwNJKJ7UJek7eTYccQ8LR7XUWFKqw6aEq5KUNlCcGvSzKqSX/vtWVVA==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [android] - requiresBuild: true - dev: false - optional: true - - /@parcel/watcher-darwin-arm64@2.3.0: - resolution: {integrity: sha512-mKY+oijI4ahBMc/GygVGvEdOq0L4DxhYgwQqYAz/7yPzuGi79oXrZG52WdpGA1wLBPrYb0T8uBaGFo7I6rvSKw==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [darwin] - requiresBuild: true - dev: false - optional: true - - /@parcel/watcher-darwin-x64@2.3.0: - resolution: {integrity: sha512-20oBj8LcEOnLE3mgpy6zuOq8AplPu9NcSSSfyVKgfOhNAc4eF4ob3ldj0xWjGGbOF7Dcy1Tvm6ytvgdjlfUeow==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [darwin] - requiresBuild: true - dev: false - optional: true - - /@parcel/watcher-freebsd-x64@2.3.0: - resolution: {integrity: sha512-7LftKlaHunueAEiojhCn+Ef2CTXWsLgTl4hq0pkhkTBFI3ssj2bJXmH2L67mKpiAD5dz66JYk4zS66qzdnIOgw==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [freebsd] - requiresBuild: true - dev: false - optional: true - - /@parcel/watcher-linux-arm-glibc@2.3.0: - resolution: {integrity: sha512-1apPw5cD2xBv1XIHPUlq0cO6iAaEUQ3BcY0ysSyD9Kuyw4MoWm1DV+W9mneWI+1g6OeP6dhikiFE6BlU+AToTQ==} - engines: {node: '>= 10.0.0'} - cpu: [arm] - os: [linux] - requiresBuild: true - dev: false - optional: true - - /@parcel/watcher-linux-arm64-glibc@2.3.0: - resolution: {integrity: sha512-mQ0gBSQEiq1k/MMkgcSB0Ic47UORZBmWoAWlMrTW6nbAGoLZP+h7AtUM7H3oDu34TBFFvjy4JCGP43JlylkTQA==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [linux] - requiresBuild: true - dev: false - optional: true - - /@parcel/watcher-linux-arm64-musl@2.3.0: - resolution: {integrity: sha512-LXZAExpepJew0Gp8ZkJ+xDZaTQjLHv48h0p0Vw2VMFQ8A+RKrAvpFuPVCVwKJCr5SE+zvaG+Etg56qXvTDIedw==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [linux] - requiresBuild: true - dev: false - optional: true - - /@parcel/watcher-linux-x64-glibc@2.3.0: - resolution: {integrity: sha512-P7Wo91lKSeSgMTtG7CnBS6WrA5otr1K7shhSjKHNePVmfBHDoAOHYRXgUmhiNfbcGk0uMCHVcdbfxtuiZCHVow==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [linux] - requiresBuild: true - dev: false - optional: true - - /@parcel/watcher-linux-x64-musl@2.3.0: - resolution: {integrity: sha512-+kiRE1JIq8QdxzwoYY+wzBs9YbJ34guBweTK8nlzLKimn5EQ2b2FSC+tAOpq302BuIMjyuUGvBiUhEcLIGMQ5g==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [linux] - requiresBuild: true - dev: false - optional: true - - /@parcel/watcher-wasm@2.3.0: - resolution: {integrity: sha512-ejBAX8H0ZGsD8lSICDNyMbSEtPMWgDL0WFCt/0z7hyf5v8Imz4rAM8xY379mBsECkq/Wdqa5WEDLqtjZ+6NxfA==} - engines: {node: '>= 10.0.0'} - dependencies: - is-glob: 4.0.3 - micromatch: 4.0.5 - napi-wasm: 1.1.0 - dev: false - bundledDependencies: - - napi-wasm - - /@parcel/watcher-win32-arm64@2.3.0: - resolution: {integrity: sha512-35gXCnaz1AqIXpG42evcoP2+sNL62gZTMZne3IackM+6QlfMcJLy3DrjuL6Iks7Czpd3j4xRBzez3ADCj1l7Aw==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [win32] - requiresBuild: true - dev: false - optional: true - - /@parcel/watcher-win32-ia32@2.3.0: - resolution: {integrity: sha512-FJS/IBQHhRpZ6PiCjFt1UAcPr0YmCLHRbTc00IBTrelEjlmmgIVLeOx4MSXzx2HFEy5Jo5YdhGpxCuqCyDJ5ow==} - engines: {node: '>= 10.0.0'} - cpu: [ia32] - os: [win32] - requiresBuild: true - dev: false - optional: true - - /@parcel/watcher-win32-x64@2.3.0: - resolution: {integrity: sha512-dLx+0XRdMnVI62kU3wbXvbIRhLck4aE28bIGKbRGS7BJNt54IIj9+c/Dkqb+7DJEbHUZAX1bwaoM8PqVlHJmCA==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [win32] - requiresBuild: true - dev: false - optional: true - - /@parcel/watcher@2.3.0: - resolution: {integrity: sha512-pW7QaFiL11O0BphO+bq3MgqeX/INAk9jgBldVDYjlQPO4VddoZnF22TcF9onMhnLVHuNqBJeRf+Fj7eezi/+rQ==} - engines: {node: '>= 10.0.0'} - dependencies: - detect-libc: 1.0.3 - is-glob: 4.0.3 - micromatch: 4.0.5 - node-addon-api: 7.0.0 - optionalDependencies: - '@parcel/watcher-android-arm64': 2.3.0 - '@parcel/watcher-darwin-arm64': 2.3.0 - '@parcel/watcher-darwin-x64': 2.3.0 - '@parcel/watcher-freebsd-x64': 2.3.0 - '@parcel/watcher-linux-arm-glibc': 2.3.0 - '@parcel/watcher-linux-arm64-glibc': 2.3.0 - '@parcel/watcher-linux-arm64-musl': 2.3.0 - '@parcel/watcher-linux-x64-glibc': 2.3.0 - '@parcel/watcher-linux-x64-musl': 2.3.0 - '@parcel/watcher-win32-arm64': 2.3.0 - '@parcel/watcher-win32-ia32': 2.3.0 - '@parcel/watcher-win32-x64': 2.3.0 - dev: false - /@pnpm/config.env-replace@1.1.0: resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} engines: {node: '>=12.22.0'} @@ -1666,10 +1520,6 @@ packages: normalize-path: 3.0.0 picomatch: 2.3.1 - /arch@2.2.0: - resolution: {integrity: sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==} - dev: false - /array-buffer-byte-length@1.0.0: resolution: {integrity: sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==} dependencies: @@ -1935,12 +1785,6 @@ packages: engines: {node: '>=8'} dev: false - /citty@0.1.4: - resolution: {integrity: sha512-Q3bK1huLxzQrvj7hImJ7Z1vKYJRPQCDnd0EjXfHMidcjecGOMuLrmuQmtWmFkuKLcMThlGh1yCKG8IEc6VeNXQ==} - dependencies: - consola: 3.2.3 - dev: false - /cli-boxes@3.0.0: resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} engines: {node: '>=10'} @@ -1965,15 +1809,6 @@ packages: string-width: 7.0.0 dev: true - /clipboardy@3.0.0: - resolution: {integrity: sha512-Su+uU5sr1jkUy1sGRpLKjKrvEOVXgSgiSInwa/qeID6aJ07yh+5NWc3h2QfjHjBnfX4LhtFcuAWKUsJ3r+fjbg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - dependencies: - arch: 2.2.0 - execa: 5.1.1 - is-wsl: 2.2.0 - dev: false - /cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -1983,11 +1818,6 @@ packages: wrap-ansi: 7.0.0 dev: false - /cluster-key-slot@1.1.2: - resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} - engines: {node: '>=0.10.0'} - dev: false - /color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} dependencies: @@ -2076,10 +1906,6 @@ packages: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} dev: true - /cookie-es@1.0.0: - resolution: {integrity: sha512-mWYvfOLrfEc996hlKcdABeIiPHUPC6DM2QYZdGGOvhOTbA3tjm2eBwqlJpoFdjC89NI4Qt6h0Pu06Mp+1Pj5OQ==} - dev: false - /core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} dev: false @@ -2202,26 +2028,14 @@ packages: resolution: {integrity: sha512-Vy2wmG3NTkmHNg/kzpuvHhkqeIx3ODWqasgCRbKtbXEN0G+HpEEv9BtJLp7ZG1CZloFaC41Ah3ZFbq7aqCqMeQ==} dev: false - /denque@2.1.0: - resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} - engines: {node: '>=0.10'} - dev: false - /dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} - dev: true /destr@2.0.2: resolution: {integrity: sha512-65AlobnZMiCET00KaFFjUefxDX0khFA/E4myqZ7a6Sq1yZtR8+FVIvilVX66vF2uobSumxooYZChiRPCKNqhmg==} dev: false - /detect-libc@1.0.3: - resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} - engines: {node: '>=0.10'} - hasBin: true - dev: false - /devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} dependencies: @@ -2481,6 +2295,7 @@ packages: onetime: 5.1.2 signal-exit: 3.0.7 strip-final-newline: 2.0.0 + dev: true /execa@8.0.1: resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} @@ -2643,10 +2458,6 @@ packages: has-symbols: 1.0.3 dev: true - /get-port-please@3.1.1: - resolution: {integrity: sha512-3UBAyM3u4ZBVYDsxOQfJDxEa6XTbpBDrOjp4mf7ExFRt5BKs/QywQQiJsh2B+hxcZLSapWqCRvElUe8DnKcFHA==} - dev: false - /get-port@7.0.0: resolution: {integrity: sha512-mDHFgApoQd+azgMdwylJrv2DX47ywGq1i5VFJE7fZ0dttNq3iQMfsU4IvEgBHojA3KqEudyu7Vq+oN8kNaNkWw==} engines: {node: '>=16'} @@ -2796,19 +2607,6 @@ packages: resolution: {integrity: sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw==} dev: false - /h3@1.8.2: - resolution: {integrity: sha512-1Ca0orJJlCaiFY68BvzQtP2lKLk46kcLAxVM8JgYbtm2cUg6IY7pjpYgWMwUvDO9QI30N5JAukOKoT8KD3Q0PQ==} - dependencies: - cookie-es: 1.0.0 - defu: 6.1.3 - destr: 2.0.2 - iron-webcrypto: 0.10.1 - radix3: 1.1.0 - ufo: 1.3.1 - uncrypto: 0.1.3 - unenv: 1.7.4 - dev: false - /happy-dom@12.10.3: resolution: {integrity: sha512-JzUXOh0wdNGY54oKng5hliuBkq/+aT1V3YpTM+lrN/GoLQTANZsMaIvmHiHe612rauHvPJnDZkZ+5GZR++1Abg==} dependencies: @@ -2978,11 +2776,6 @@ packages: resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==} dev: false - /http-shutdown@1.2.2: - resolution: {integrity: sha512-S9wWkJ/VSY9/k4qcjG318bqJNruzE4HySUhFYknwmu6LBP97KLLfwNf+n4V1BHurvFNkSKLFnK/RsuUnRTf9Vw==} - engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} - dev: false - /http2-wrapper@2.2.0: resolution: {integrity: sha512-kZB0wxMo0sh1PehyjJUWRFEd99KC5TLjZ2cULC4f9iqJBAmKQQXEICjxl5iPJRwP40dpeHFqqhm7tYCvODpqpQ==} engines: {node: '>=10.19.0'} @@ -3004,6 +2797,7 @@ packages: /human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} + dev: true /human-signals@5.0.0: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} @@ -3079,27 +2873,6 @@ packages: side-channel: 1.0.4 dev: true - /ioredis@5.3.2: - resolution: {integrity: sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==} - engines: {node: '>=12.22.0'} - dependencies: - '@ioredis/commands': 1.2.0 - cluster-key-slot: 1.1.2 - debug: 4.3.4 - denque: 2.1.0 - lodash.defaults: 4.2.0 - lodash.isarguments: 3.1.0 - redis-errors: 1.2.0 - redis-parser: 3.0.0 - standard-as-callback: 2.1.0 - transitivePeerDependencies: - - supports-color - dev: false - - /iron-webcrypto@0.10.1: - resolution: {integrity: sha512-QGOS8MRMnj/UiOa+aMIgfyHcvkhqNUsUxb1XzskENvbo+rEfp6TOwqd1KPuDzXC4OnGHcMSVxDGRoilqB8ViqA==} - dev: false - /is-absolute@0.1.7: resolution: {integrity: sha512-Xi9/ZSn4NFapG8RP98iNPMOeaV3mXPisxKxzKtHVqr3g56j/fBn+yZmnxSVAA8lmZbl2J9b/a4kJvfU3hqQYgA==} engines: {node: '>=0.10.0'} @@ -3276,6 +3049,7 @@ packages: /is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} + dev: true /is-stream@3.0.0: resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} @@ -3517,29 +3291,6 @@ packages: - supports-color dev: true - /listhen@1.5.5: - resolution: {integrity: sha512-LXe8Xlyh3gnxdv4tSjTjscD1vpr/2PRpzq8YIaMJgyKzRG8wdISlWVWnGThJfHnlJ6hmLt2wq1yeeix0TEbuoA==} - hasBin: true - dependencies: - '@parcel/watcher': 2.3.0 - '@parcel/watcher-wasm': 2.3.0 - citty: 0.1.4 - clipboardy: 3.0.0 - consola: 3.2.3 - defu: 6.1.3 - get-port-please: 3.1.1 - h3: 1.8.2 - http-shutdown: 1.2.2 - jiti: 1.21.0 - mlly: 1.4.2 - node-forge: 1.3.1 - pathe: 1.1.1 - std-env: 3.4.3 - ufo: 1.3.1 - untun: 0.1.2 - uqr: 0.1.2 - dev: false - /listr2@8.0.0: resolution: {integrity: sha512-u8cusxAcyqAiQ2RhYvV7kRKNLgUvtObIbhOX2NCXqvp1UU32xIg5CT22ykS2TPKJXZWJwtK3IKLiqAGlGNE+Zg==} engines: {node: '>=18.0.0'} @@ -3579,14 +3330,6 @@ packages: mlly: 1.4.2 pkg-types: 1.0.3 - /lodash.defaults@4.2.0: - resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} - dev: false - - /lodash.isarguments@3.1.0: - resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} - dev: false - /lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -3630,13 +3373,6 @@ packages: engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} dev: false - /lru-cache@10.0.2: - resolution: {integrity: sha512-Yj9mA8fPiVgOUpByoTZO5pNrcl5Yk37FcSHsUINpAsaBIEZIuqcCclDZJCVxqQShDsmYX8QG63svJiTbOATZwg==} - engines: {node: 14 || >=16.14} - dependencies: - semver: 7.5.4 - dev: false - /lru-cache@6.0.0: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} @@ -3745,12 +3481,6 @@ packages: braces: 3.0.2 picomatch: 2.3.1 - /mime@3.0.0: - resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} - engines: {node: '>=10.0.0'} - hasBin: true - dev: false - /mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -3899,10 +3629,6 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - /napi-wasm@1.1.0: - resolution: {integrity: sha512-lHwIAJbmLSjF9VDRm9GoVOy9AGp3aIvkjv+Kvz9h16QR3uSVYH78PNQUnT2U4X53mhlnV2M7wrhibQ3GHicDmg==} - dev: false - /ncp@2.0.0: resolution: {integrity: sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA==} hasBin: true @@ -3914,10 +3640,6 @@ packages: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} dev: true - /node-addon-api@7.0.0: - resolution: {integrity: sha512-vgbBJTS4m5/KkE16t5Ly0WW9hz46swAstv0hYYwMtbG7AznRhNyfLRe8HZAiWIpcHzoO7HxhLuBQj9rJ/Ho0ZA==} - dev: false - /node-fetch-native@1.4.1: resolution: {integrity: sha512-NsXBU0UgBxo2rQLOeWNZqS3fvflWePMECr8CoSWoSTqCqGbVVsvl9vZu1HfQicYN0g5piV9Gh8RTEvo/uP752w==} dev: false @@ -4000,6 +3722,7 @@ packages: engines: {node: '>=8'} dependencies: path-key: 3.1.1 + dev: true /npm-run-path@5.1.0: resolution: {integrity: sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==} @@ -4036,14 +3759,6 @@ packages: object-keys: 1.1.1 dev: true - /ofetch@1.3.3: - resolution: {integrity: sha512-s1ZCMmQWXy4b5K/TW9i/DtiN8Ku+xCiHcjQ6/J/nDdssirrQNOoB165Zu8EqLMA2lln1JUth9a0aW9Ap2ctrUg==} - dependencies: - destr: 2.0.2 - node-fetch-native: 1.4.1 - ufo: 1.3.1 - dev: false - /ohash@1.1.3: resolution: {integrity: sha512-zuHHiGTYTA1sYJ/wZN+t5HKZaH23i4yI1HMwbuXm24Nid7Dv0KcuRlKoNKS9UNfAVSBlnGLcuQrnOKWOZoEGaw==} dev: false @@ -4327,10 +4042,6 @@ packages: engines: {node: '>=10'} dev: false - /radix3@1.1.0: - resolution: {integrity: sha512-pNsHDxbGORSvuSScqNJ+3Km6QAVqk8CfsCBIEoDgpqLrkD2f3QM4I7d1ozJJ172OmIcoUcerZaNWqtLkRXTV3A==} - dev: false - /rc9@2.1.1: resolution: {integrity: sha512-lNeOl38Ws0eNxpO3+wD1I9rkHGQyj1NU1jlzv4go2CtEnEQEUfqnIvZG7W+bC/aXdJ27n5x/yUjb6RoT9tko+Q==} dependencies: @@ -4405,18 +4116,6 @@ packages: dependencies: picomatch: 2.3.1 - /redis-errors@1.2.0: - resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} - engines: {node: '>=4'} - dev: false - - /redis-parser@3.0.0: - resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} - engines: {node: '>=4'} - dependencies: - redis-errors: 1.2.0 - dev: false - /regenerator-runtime@0.14.0: resolution: {integrity: sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==} dev: false @@ -4797,14 +4496,6 @@ packages: /stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - /standard-as-callback@2.1.0: - resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} - dev: false - - /std-env@3.4.3: - resolution: {integrity: sha512-f9aPhy8fYBuMN+sNfakZV18U39PbalgjXG3lLB9WkaYTxijru61wb57V9wxxNthXM5Sd88ETBWi29qLAsHO52Q==} - dev: false - /std-env@3.6.0: resolution: {integrity: sha512-aFZ19IgVmhdB2uX599ve2kE6BIE3YMnQ6Gp6BURhW/oIzpXGKr878TQfAQZn1+i0Flcc/UKUy1gOlcfaUBCryg==} @@ -4929,6 +4620,7 @@ packages: /strip-final-newline@2.0.0: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} + dev: true /strip-final-newline@3.0.0: resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} @@ -5219,23 +4911,9 @@ packages: which-boxed-primitive: 1.0.2 dev: true - /uncrypto@0.1.3: - resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} - dev: false - /undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} - /unenv@1.7.4: - resolution: {integrity: sha512-fjYsXYi30It0YCQYqLOcT6fHfMXsBr2hw9XC7ycf8rTG7Xxpe3ZssiqUnD0khrjiZEmkBXWLwm42yCSCH46fMw==} - dependencies: - consola: 3.2.3 - defu: 6.1.3 - mime: 3.0.0 - node-fetch-native: 1.4.1 - pathe: 1.1.1 - dev: false - /unimport@3.4.0: resolution: {integrity: sha512-M/lfFEgufIT156QAr/jWHLUn55kEmxBBiQsMxvRSIbquwmeJEyQYgshHDEvQDWlSJrVOOTAgnJ3FvlsrpGkanA==} dependencies: @@ -5313,68 +4991,6 @@ packages: webpack-virtual-modules: 0.5.0 dev: false - /unstorage@1.9.0: - resolution: {integrity: sha512-VpD8ZEYc/le8DZCrny3bnqKE4ZjioQxBRnWE+j5sGNvziPjeDlaS1NaFFHzl/kkXaO3r7UaF8MGQrs14+1B4pQ==} - peerDependencies: - '@azure/app-configuration': ^1.4.1 - '@azure/cosmos': ^3.17.3 - '@azure/data-tables': ^13.2.2 - '@azure/identity': ^3.2.3 - '@azure/keyvault-secrets': ^4.7.0 - '@azure/storage-blob': ^12.14.0 - '@capacitor/preferences': ^5.0.0 - '@planetscale/database': ^1.8.0 - '@upstash/redis': ^1.22.0 - '@vercel/kv': ^0.2.2 - idb-keyval: ^6.2.1 - peerDependenciesMeta: - '@azure/app-configuration': - optional: true - '@azure/cosmos': - optional: true - '@azure/data-tables': - optional: true - '@azure/identity': - optional: true - '@azure/keyvault-secrets': - optional: true - '@azure/storage-blob': - optional: true - '@capacitor/preferences': - optional: true - '@planetscale/database': - optional: true - '@upstash/redis': - optional: true - '@vercel/kv': - optional: true - idb-keyval: - optional: true - dependencies: - anymatch: 3.1.3 - chokidar: 3.5.3 - destr: 2.0.2 - h3: 1.8.2 - ioredis: 5.3.2 - listhen: 1.5.5 - lru-cache: 10.0.2 - mri: 1.2.0 - node-fetch-native: 1.4.1 - ofetch: 1.3.3 - ufo: 1.3.1 - transitivePeerDependencies: - - supports-color - dev: false - - /untun@0.1.2: - resolution: {integrity: sha512-wLAMWvxfqyTiBODA1lg3IXHQtjggYLeTK7RnSfqtOXixWJ3bAa2kK/HHmOOg19upteqO3muLvN6O/icbyQY33Q==} - hasBin: true - dependencies: - citty: 0.1.4 - consola: 3.2.3 - pathe: 1.1.1 - dev: false - /update-notifier@6.0.2: resolution: {integrity: sha512-EDxhTEVPZZRLWYcJ4ZXjGFN0oP7qYvbXWzEgRm/Yql4dHX5wDbvh89YHP6PK1lzZJYrMtXUuZZz8XGK+U6U1og==} engines: {node: '>=14.16'} @@ -5395,10 +5011,6 @@ packages: xdg-basedir: 5.1.0 dev: false - /uqr@0.1.2: - resolution: {integrity: sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==} - dev: false - /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} dev: false diff --git a/src/__tests__/storage.test.ts b/src/__tests__/storage.test.ts index 3117b8f8..66180901 100644 --- a/src/__tests__/storage.test.ts +++ b/src/__tests__/storage.test.ts @@ -1,11 +1,12 @@ import { fakeBrowser } from '@webext-core/fake-browser'; -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { browser } from '~/browser'; import { storage } from '~/storage'; describe('Storage Utils', () => { beforeEach(() => { fakeBrowser.reset(); + storage.unwatch(); }); describe.each(['local', 'sync', 'managed', 'session'] as const)( @@ -15,33 +16,80 @@ describe('Storage Utils', () => { it('should return the value from the correct storage area', async () => { const expected = 123; await fakeBrowser.storage[storageArea].set({ count: expected }); - expect(await storage.getItem(`${storageArea}:count`)).toBe(expected); + + const actual = await storage.getItem(`${storageArea}:count`); + + expect(actual).toBe(expected); }); it("should return null if the value doesn't exist", async () => { - expect(await storage.getItem(`${storageArea}:count`)).toBeNull(); + const actual = await storage.getItem(`${storageArea}:count`); + + expect(actual).toBeNull(); + }); + + it('should return the default value if passed in options', async () => { + const expected = 0; + const actual = await storage.getItem(`${storageArea}:count`, { + defaultValue: expected, + }); + + expect(actual).toBe(expected); }); }); describe('getItems', () => { - it('should return an array of values in the same order as the keys passed in', async () => { + it('should return an array of values', async () => { const expected = [ { key: `${storageArea}:count`, value: 234 }, { key: `${storageArea}:installDate`, value: null }, + { key: `${storageArea}:otherValue`, value: 345 }, + ]; + const params = [ + expected[0].key, + expected[1].key, + { + key: expected[2].key, + options: { defaultValue: expected[2].value }, + }, ]; - const keys = expected.map((item) => item.key); await fakeBrowser.storage[storageArea].set({ count: expected[0].value, }); - const actual = await storage.getItems(keys); + const actual = await storage.getItems(params); + + expect(actual).toHaveLength(3); + expected.forEach((item) => { + expect(actual).toContainEqual(item); + }); + }); + }); + + describe('getMeta', () => { + it('should return item metadata from key+$', async () => { + const expected = { v: 1 }; + await fakeBrowser.storage[storageArea].set({ count$: expected }); + + const actual = await storage.getMeta(`${storageArea}:count`); expect(actual).toEqual(expected); }); + + it('should return an empty object if missing', async () => { + const actual = await storage.getMeta(`${storageArea}:count`); + + expect(actual).toEqual({}); + }); }); describe('setItem', () => { - it('should set the value in the correct storage area', () => {}); + it('should set the value in the correct storage area', async () => { + const key = `${storageArea}:count`; + const value = 321; + + await storage.setItem(key, value); + }); it.each([undefined, null])( 'should remove the item from storage when setting the value to %s', @@ -57,12 +105,685 @@ describe('Storage Utils', () => { ); }); + describe('setItems', () => { + it('should set multiple items in storage', async () => { + const expected = [ + { key: `${storageArea}:count`, value: 234 }, + { key: `${storageArea}:installDate`, value: null }, + ]; + await fakeBrowser.storage[storageArea].set({ + count: 123, + installDate: 321, + }); + + await storage.setItems(expected); + const actual = await storage.getItems( + expected.map((item) => item.key), + ); + + expect(actual).toHaveLength(2); + expected.forEach((item) => { + expect(actual).toContainEqual(item); + }); + }); + }); + + describe('setMeta', () => { + it('should set metadata at key+$', async () => { + const existing = { v: 1 }; + await browser.storage[storageArea].set({ count$: existing }); + const newValues = { + date: Date.now(), + }; + const expected = { ...existing, ...newValues }; + + await storage.setMeta(`${storageArea}:count`, newValues); + const actual = await storage.getMeta(`${storageArea}:count`); + + expect(actual).toEqual(expected); + }); + + it.each([undefined, null])( + 'should remove any properties set to %s', + async (version) => { + const existing = { v: 1 }; + await browser.storage[storageArea].set({ count$: existing }); + const expected = {}; + + await storage.setMeta(`${storageArea}:count`, { v: version }); + const actual = await storage.getMeta(`${storageArea}:count`); + + expect(actual).toEqual(expected); + }, + ); + }); + describe('removeItem', () => { it('should remove the key from storage', async () => { await fakeBrowser.storage[storageArea].set({ count: 456 }); - await storage.removeItem(`${storageArea}:count`); - expect(await browser.storage[storageArea].get()).toEqual({}); + await storage.removeItem(`${storageArea}:count`); + const actual = await storage.getItem(`${storageArea}:count`); + + expect(actual).toBeNull(); + }); + + it('should not remove the metadata by default', async () => { + const expected = { v: 1 }; + await fakeBrowser.storage[storageArea].set({ + count$: expected, + count: 3, + }); + + await storage.removeItem(`${storageArea}:count`); + const actual = await storage.getMeta(`${storageArea}:count`); + + expect(actual).toEqual(expected); + }); + + it('should remove the metadata when requested', async () => { + await fakeBrowser.storage[storageArea].set({ + count$: { v: 1 }, + count: 3, + }); + + await storage.removeItem(`${storageArea}:count`, { + removeMeta: true, + }); + const actual = await storage.getMeta(`${storageArea}:count`); + + expect(actual).toEqual({}); + }); + }); + + describe('removeItems', () => { + it('should remove multiple items', async () => { + const key1 = `${storageArea}:one`; + const key2 = `${storageArea}:two`; + const key3 = `${storageArea}:three`; + await fakeBrowser.storage[storageArea].set({ + ['one']: '1', + ['two']: null, + ['two$']: { v: 1 }, + ['three']: '1', + ['three$']: { v: 1 }, + }); + + await storage.removeItems([ + key1, + key2, + { key: key3, options: { removeMeta: true } }, + ]); + + expect(await storage.getItem(key1)).toBeNull(); + expect(await storage.getItem(key2)).toBeNull(); + expect(await storage.getMeta(key2)).toEqual({ v: 1 }); + expect(await storage.getItem(key3)).toBeNull(); + expect(await storage.getMeta(key3)).toEqual({}); + }); + }); + + describe('removeMeta', () => { + it('should remove all metadata', async () => { + await fakeBrowser.storage[storageArea].set({ count$: { v: 4 } }); + + await storage.removeMeta(`${storageArea}:count`); + const actual = await storage.getMeta(`${storageArea}:count`); + + expect(actual).toEqual({}); + }); + + it('should only remove specific properties', async () => { + await fakeBrowser.storage[storageArea].set({ + count$: { v: 4, d: Date.now() }, + }); + + await storage.removeMeta(`${storageArea}:count`, ['d']); + const actual = await storage.getMeta(`${storageArea}:count`); + + expect(actual).toEqual({ v: 4 }); + }); + }); + + describe('snapshot', () => { + it('should return a snapshot of the entire storage without area prefixes', async () => { + const expected = { + count: 1, + count$: { v: 2 }, + example: 'test', + }; + + await fakeBrowser.storage[storageArea].set(expected); + const actual = await storage.snapshot(storageArea); + + expect(actual).toEqual(expected); + }); + + it('should exclude specific properties and their metadata', async () => { + const input = { + count: 1, + count$: { v: 2 }, + example: 'test', + }; + const excludeKeys = ['count']; + const expected = { + example: 'test', + }; + + await fakeBrowser.storage[storageArea].set(input); + const actual = await storage.snapshot(storageArea, { excludeKeys }); + + expect(actual).toEqual(expected); + }); + }); + + describe('restoreSnapshot', () => { + it('should restore a snapshot object by setting all values in storage', async () => { + const data = { + one: 'one', + two: 'two', + }; + const existing = { + two: 'two-two', + three: 'three', + }; + await fakeBrowser.storage[storageArea].set(existing); + + await storage.restoreSnapshot(storageArea, data); + const actual = await storage.snapshot(storageArea); + + expect(actual).toEqual({ ...existing, ...data }); + }); + + it('should overwrite, not merge, any metadata keys in the snapshot', async () => { + const existing = { + count: 1, + count$: { + v: 2, + }, + }; + const data = { + count$: { + restoredAt: Date.now(), + }, + }; + const expected = { + ...existing, + count$: data.count$, + }; + await fakeBrowser.storage[storageArea].set(existing); + + await storage.restoreSnapshot(storageArea, data); + const actual = await storage.snapshot(storageArea); + + expect(actual).toEqual(expected); + }); + }); + + describe('watch', () => { + it('should not trigger if the changed key is different from the requested key', async () => { + const cb = vi.fn(); + + storage.watch(`${storageArea}:key`, cb); + await storage.setItem(`${storageArea}:not-the-key`, '123'); + + expect(cb).not.toBeCalled(); + }); + + it("should not trigger if the value doesn't change", async () => { + const cb = vi.fn(); + const value = '123'; + + await storage.setItem(`${storageArea}:key`, value); + storage.watch(`${storageArea}:key`, cb); + await storage.setItem(`${storageArea}:key`, value); + + expect(cb).not.toBeCalled(); + }); + + it('should call the callback when the value changes', async () => { + const cb = vi.fn(); + const newValue = '123'; + const oldValue = null; + + storage.watch(`${storageArea}:key`, cb); + await storage.setItem(`${storageArea}:key`, newValue); + + expect(cb).toBeCalledTimes(1); + expect(cb).toBeCalledWith(newValue, oldValue); + }); + + it('should remove the listener when calling the returned function', async () => { + const cb = vi.fn(); + + const unwatch = storage.watch(`${storageArea}:key`, cb); + unwatch(); + await storage.setItem(`${storageArea}:key`, '123'); + + expect(cb).not.toBeCalled(); + }); + }); + + describe('unwatch', () => { + it('should remove all watch listeners', async () => { + const cb = vi.fn(); + + storage.watch(`${storageArea}:key`, cb); + storage.unwatch(); + await storage.setItem(`${storageArea}:key`, '123'); + + expect(cb).not.toBeCalled(); + }); + }); + + describe('defineItem', () => { + describe('versioning', () => { + /** + * This works because fakeBrowser is syncrounous, and is will finish any number of chained + * calls within a single tick of the event loop, ie: a timeout of 0. + */ + async function waitForMigrations() { + return new Promise((res) => setTimeout(res)); + } + + it('should migrate values to the latest when a version upgrade is detected', async () => { + await fakeBrowser.storage[storageArea].set({ + count: 2, + count$: { v: 1 }, + }); + const migrateToV2 = vi.fn((oldCount) => oldCount * 2); + const migrateToV3 = vi.fn((oldCount) => oldCount * 3); + + const item = storage.defineItem( + `${storageArea}:count`, + { + version: 3, + migrations: { + 2: migrateToV2, + 3: migrateToV3, + }, + }, + ); + await waitForMigrations(); + + const actualValue = await item.getValue(); + const actualMeta = await item.getMeta(); + + expect(actualValue).toEqual(12); + expect(actualMeta).toEqual({ v: 3 }); + + expect(migrateToV2).toBeCalledTimes(1); + expect(migrateToV2).toBeCalledWith(2); + + expect(migrateToV3).toBeCalledTimes(1); + expect(migrateToV3).toBeCalledWith(4); + }); + + it("should not run migrations if the value doesn't exist yet", async () => { + const migrateToV2 = vi.fn((oldCount) => oldCount * 2); + const migrateToV3 = vi.fn((oldCount) => oldCount * 3); + + const item = storage.defineItem( + `${storageArea}:count`, + { + version: 3, + migrations: { + 2: migrateToV2, + 3: migrateToV3, + }, + }, + ); + await waitForMigrations(); + + const actualValue = await item.getValue(); + const actualMeta = await item.getMeta(); + + expect(actualValue).toBeNull(); + expect(actualMeta).toEqual({}); + + expect(migrateToV2).not.toBeCalled(); + expect(migrateToV3).not.toBeCalled(); + }); + + it('should run the v2 migration when converting an unversioned item to a versioned one', async () => { + await fakeBrowser.storage[storageArea].set({ + count: 2, + }); + const migrateToV2 = vi.fn((oldCount) => oldCount * 2); + + const item = storage.defineItem( + `${storageArea}:count`, + { + version: 2, + migrations: { + 2: migrateToV2, + }, + }, + ); + await waitForMigrations(); + + const actualValue = await item.getValue(); + const actualMeta = await item.getMeta(); + + expect(actualValue).toEqual(4); + expect(actualMeta).toEqual({ v: 2 }); + + expect(migrateToV2).toBeCalledTimes(1); + expect(migrateToV2).toBeCalledWith(2); + }); + + it('Should not run old migrations if the version is unchanged', async () => { + await fakeBrowser.storage[storageArea].set({ + count: 2, + count$: { v: 3 }, + }); + const migrateToV2 = vi.fn((oldCount) => oldCount * 2); + const migrateToV3 = vi.fn((oldCount) => oldCount * 3); + + storage.defineItem(`${storageArea}:count`, { + version: 3, + migrations: { + 2: migrateToV2, + 3: migrateToV3, + }, + }); + await waitForMigrations(); + + expect(migrateToV2).not.toBeCalled(); + expect(migrateToV3).not.toBeCalled(); + }); + + it('should skip missing migration functions', async () => { + await fakeBrowser.storage[storageArea].set({ + count: 2, + count$: { v: 0 }, + }); + const migrateToV1 = vi.fn((oldCount) => oldCount * 1); + const migrateToV3 = vi.fn((oldCount) => oldCount * 3); + + const item = storage.defineItem( + `${storageArea}:count`, + { + version: 3, + migrations: { + 1: migrateToV1, + 3: migrateToV3, + }, + }, + ); + await waitForMigrations(); + + const actualValue = await item.getValue(); + const actualMeta = await item.getMeta(); + + expect(actualValue).toEqual(6); + expect(actualMeta).toEqual({ v: 3 }); + + expect(migrateToV1).toBeCalledTimes(1); + expect(migrateToV1).toBeCalledWith(2); + + expect(migrateToV3).toBeCalledTimes(1); + expect(migrateToV3).toBeCalledWith(2); + }); + + it('should throw an error if the new version is less than the previous version', async () => { + const prevVersion = 2; + const nextVersion = 1; + await fakeBrowser.storage[storageArea].set({ + count: 0, + count$: { v: prevVersion }, + }); + + const item = storage.defineItem(`${storageArea}:count`, { + version: nextVersion, + }); + + // @ts-expect-error: _migrationsCompleted is returned, but untyped + await expect(item._migrationsCompleted).rejects.toThrow( + 'version downgrade detected', + ); + }); + }); + + describe('getValue', () => { + it('should return the value from storage', async () => { + const expected = 2; + const item = storage.defineItem(`${storageArea}:count`); + await fakeBrowser.storage[storageArea].set({ count: expected }); + + const actual = await item.getValue(); + + expect(actual).toBe(expected); + }); + + it('should return null if missing', async () => { + const item = storage.defineItem(`${storageArea}:count`); + + const actual = await item.getValue(); + + expect(actual).toBeNull(); + }); + + it('should return the provided default value if missing', async () => { + const expected = 0; + const item = storage.defineItem(`${storageArea}:count`, { + defaultValue: expected, + }); + + const actual = await item.getValue(); + + expect(actual).toEqual(expected); + }); + }); + + describe('getMeta', () => { + it('should return the value from storage at key+$', async () => { + const expected = { v: 2 }; + const item = storage.defineItem( + `${storageArea}:count`, + ); + await fakeBrowser.storage[storageArea].set({ count$: expected }); + + const actual = await item.getMeta(); + + expect(actual).toBe(expected); + }); + + it('should return an empty object if missing', async () => { + const expected = {}; + const item = storage.defineItem( + `${storageArea}:count`, + ); + + const actual = await item.getMeta(); + + expect(actual).toEqual(expected); + }); + }); + + describe('setValue', () => { + it('should set the value in storage', async () => { + const expected = 1; + const item = storage.defineItem(`${storageArea}:count`); + + await item.setValue(expected); + const actual = await item.getValue(); + + expect(actual).toBe(expected); + }); + + it.each([undefined, null])( + 'should remove the value in storage when %s is passed in', + async (value) => { + const item = storage.defineItem(`${storageArea}:count`); + + // @ts-expect-error: undefined is not assignable to null, but we're testing that case on purpose + await item.setValue(value); + const actual = await item.getValue(); + + expect(actual).toBeNull(); + }, + ); + }); + + describe('setMeta', () => { + it('should set metadata at key+$', async () => { + const expected = { date: Date.now() }; + const item = storage.defineItem( + `${storageArea}:count`, + ); + + await item.setMeta(expected); + const actual = await item.getMeta(); + + expect(actual).toEqual(expected); + }); + + it('should add to metadata if already present', async () => { + const existing = { v: 2 }; + const newFields = { date: Date.now() }; + const expected = { ...existing, ...newFields }; + const item = storage.defineItem< + number, + { date: number; v: number } + >(`${storageArea}:count`); + await fakeBrowser.storage[storageArea].set({ + count$: existing, + }); + + await item.setMeta(newFields); + const actual = await item.getMeta(); + + expect(actual).toEqual(expected); + }); + }); + + describe('removeValue', () => { + it('should remove the key from storage', async () => { + const item = storage.defineItem(`${storageArea}:count`); + await fakeBrowser.storage[storageArea].set({ count: 456 }); + + await item.removeValue(); + const actual = await item.getValue(); + + expect(actual).toBeNull(); + }); + + it('should not remove the metadata by default', async () => { + const item = storage.defineItem(`${storageArea}:count`); + const expected = { v: 1 }; + await fakeBrowser.storage[storageArea].set({ + count$: expected, + count: 3, + }); + + await item.removeValue(); + const actual = await item.getMeta(); + + expect(actual).toEqual(expected); + }); + + it('should remove the metadata when requested', async () => { + const item = storage.defineItem(`${storageArea}:count`); + await fakeBrowser.storage[storageArea].set({ + count$: { v: 1 }, + count: 3, + }); + + await item.removeValue({ removeMeta: true }); + const actual = await item.getMeta(); + + expect(actual).toEqual({}); + }); + }); + + describe('removeMeta', () => { + it('should remove all metadata', async () => { + const item = storage.defineItem( + `${storageArea}:count`, + ); + await fakeBrowser.storage[storageArea].set({ count$: { v: 4 } }); + + await item.removeMeta(); + const actual = await item.getMeta(); + + expect(actual).toEqual({}); + }); + + it('should only remove specific properties', async () => { + const item = storage.defineItem( + `${storageArea}:count`, + ); + await fakeBrowser.storage[storageArea].set({ + count$: { v: 4, d: Date.now() }, + }); + + await item.removeMeta(['d']); + const actual = await item.getMeta(); + + expect(actual).toEqual({ v: 4 }); + }); + }); + + describe('watch', () => { + it("should not trigger if the changed key is different from the item's key", async () => { + const item = storage.defineItem(`${storageArea}:key`); + const cb = vi.fn(); + + item.watch(cb); + await storage.setItem(`${storageArea}:not-the-key`, '123'); + + expect(cb).not.toBeCalled(); + }); + + it("should not trigger if the value doesn't change", async () => { + const item = storage.defineItem(`${storageArea}:key`); + const cb = vi.fn(); + const value = '123'; + + await item.setValue(value); + item.watch(cb); + await item.setValue(value); + + expect(cb).not.toBeCalled(); + }); + + it('should call the callback when the value changes', async () => { + const item = storage.defineItem(`${storageArea}:key`); + const cb = vi.fn(); + const newValue = '123'; + const oldValue = null; + + item.watch(cb); + await item.setValue(newValue); + + expect(cb).toBeCalledTimes(1); + expect(cb).toBeCalledWith(newValue, oldValue); + }); + + it('should remove the listener when calling the returned function', async () => { + const item = storage.defineItem(`${storageArea}:key`); + const cb = vi.fn(); + + const unwatch = item.watch(cb); + unwatch(); + await item.setValue('123'); + + expect(cb).not.toBeCalled(); + }); + }); + + describe('unwatch', () => { + it('should remove all watch listeners', async () => { + const item = storage.defineItem(`${storageArea}:key`); + const cb = vi.fn(); + + item.watch(cb); + storage.unwatch(); + await item.setValue('123'); + + expect(cb).not.toBeCalled(); + }); }); }); }, diff --git a/src/storage.ts b/src/storage.ts index 42861467..1d802118 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -1,118 +1,587 @@ /** + * Simplfied storage APIs with support for versioned fields, snapshots, metadata, and item definitions. + * + * See [the guide](https://wxt.dev/guide/storage.html) for more information. + * * @module wxt/storage */ -import { - Driver, - WatchCallback, - createStorage, - defineDriver, - Storage, -} from 'unstorage'; -import { browser, Storage as BrowserStorage } from '~/browser'; +import { Storage, browser } from '~/browser'; +import { dequal } from 'dequal/lite'; -export interface WebExtensionDriverOptions { - storageArea: 'sync' | 'local' | 'managed' | 'session'; -} +export const storage = createStorage(); -export const webExtensionDriver: (opts: WebExtensionDriverOptions) => Driver = - defineDriver((opts) => { - const checkPermission = () => { - if (browser.storage == null) - throw Error( - "You must request the 'storage' permission to use webExtensionDriver", - ); - }; - - const _storageListener: ( - changes: BrowserStorage.StorageAreaSyncOnChangedChangesType, - ) => void = (changes) => { - Object.entries(changes).forEach(([key, { newValue }]) => { - _listeners.forEach((callback) => { - callback(newValue ? 'update' : 'remove', key); - }); - }); - }; - const _listeners = new Set(); +function createStorage(): WxtStorage { + const drivers: Record = { + local: createDriver('local'), + session: createDriver('session'), + sync: createDriver('sync'), + managed: createDriver('managed'), + }; + const getDriver = (area: string) => { + const driver = drivers[area]; + if (driver == null) { + const areaNames = Object.keys(drivers).join(', '); + throw Error(`Invalid area "${area}". Options: ${areaNames}`); + } + return driver; + }; + const resolveKey = (key: string) => { + const [driverArea, driverKey] = key.split(':', 2); + if (driverKey == null) + throw Error( + `Storage key should be in the form of "area:key", but recieved "${key}"`, + ); return { - name: 'web-extension:' + opts.storageArea, - async hasItem(key) { - checkPermission(); - const res = await browser.storage[opts.storageArea].get(key); - return res[key] != null; - }, - async getItem(key) { - checkPermission(); - const res = await browser.storage[opts.storageArea].get(key); - return res[key] ?? null; - }, - async getItems(items) { - checkPermission(); - const res = await browser.storage[opts.storageArea].get( - items.map((item) => item.key), + driverArea, + driverKey, + driver: getDriver(driverArea), + }; + }; + const getMetaKey = (key: string) => key + '$'; + const getValueOrDefault = (value: any, defaultValue: any) => + value ?? defaultValue ?? null; + const getMetaValue = (properties: any) => + typeof properties === 'object' && !Array.isArray(properties) + ? properties + : {}; + + const getItem = async ( + driver: WxtStorageDriver, + driverKey: string, + opts: GetItemOptions | undefined, + ) => { + const res = await driver.getItem(driverKey); + return getValueOrDefault(res, opts?.defaultValue); + }; + const getMeta = async (driver: WxtStorageDriver, driverKey: string) => { + const metaKey = getMetaKey(driverKey); + const res = await driver.getItem(metaKey); + return getMetaValue(res); + }; + const setItem = async ( + driver: WxtStorageDriver, + driverKey: string, + value: any, + ) => { + await driver.setItem(driverKey, value ?? null); + }; + const setMeta = async ( + driver: WxtStorageDriver, + driverKey: string, + properties: any | undefined, + ) => { + const metaKey = getMetaKey(driverKey); + const existingFields = getMetaValue(await driver.getItem(metaKey)); + const newFields = { ...existingFields }; + Object.entries(properties).forEach(([key, value]) => { + if (value == null) { + delete newFields[key]; + } else { + newFields[key] = value; + } + }); + await driver.setItem(metaKey, newFields); + }; + const removeItem = async ( + driver: WxtStorageDriver, + driverKey: string, + opts: RemoveItemOptions | undefined, + ) => { + await driver.removeItem(driverKey); + if (opts?.removeMeta) { + const metaKey = getMetaKey(driverKey); + await driver.removeItem(metaKey); + } + }; + const removeMeta = async ( + driver: WxtStorageDriver, + driverKey: string, + properties: string | string[] | undefined, + ) => { + const metaKey = getMetaKey(driverKey); + if (properties == null) { + await driver.removeItem(metaKey); + } else { + const newFields = getMetaValue(await driver.getItem(metaKey)); + [properties].flat().forEach((field) => delete newFields[field]); + await driver.setItem(metaKey, newFields); + } + }; + const watch = ( + driver: WxtStorageDriver, + driverKey: string, + cb: WatchCallback, + ) => { + return driver.watch(driverKey, cb); + }; + + const storage: WxtStorage = { + getItem: async (key, opts) => { + const { driver, driverKey } = resolveKey(key); + return await getItem(driver, driverKey, opts); + }, + getItems: async (keys) => { + const areaToKeyMap = new Map(); + const keyToOptsMap = new Map | undefined>(); + keys.forEach((key) => { + let keyStr: string; + let opts: GetItemOptions | undefined; + if (typeof key === 'string') { + keyStr = key; + } else { + keyStr = key.key; + opts = key.options; + } + const { driverArea, driverKey } = resolveKey(keyStr); + const keys = areaToKeyMap.get(driverArea) ?? []; + areaToKeyMap.set(driverArea, keys.concat(driverKey)); + keyToOptsMap.set(keyStr, opts); + }); + + const results = await Promise.all( + Array.from(areaToKeyMap.entries()).map(async ([driverArea, keys]) => { + const driverResults = await drivers[driverArea].getItems(keys); + return driverResults.map((driverResult) => { + const key = `${driverArea}:${driverResult.key}`; + const value = getValueOrDefault( + driverResult.value, + keyToOptsMap.get(key)?.defaultValue, + ); + return { key, value }; + }); + }), + ); + return results.flat(); + }, + getMeta: async (key) => { + const { driver, driverKey } = resolveKey(key); + return await getMeta(driver, driverKey); + }, + setItem: async (key, value) => { + const { driver, driverKey } = resolveKey(key); + await setItem(driver, driverKey, value); + }, + setItems: async (values) => { + const areaToKeyValueMap = new Map< + string, + Array<{ key: string; value: any }> + >(); + values.forEach(({ key, value }) => { + const { driverArea, driverKey } = resolveKey(key); + const values = areaToKeyValueMap.get(driverArea) ?? []; + areaToKeyValueMap.set( + driverArea, + values.concat({ key: driverKey, value }), ); - return items.map((item) => ({ - key: item.key, - value: res[item.key] ?? null, - })); - }, - async setItem(key, value) { - checkPermission(); - await browser.storage[opts.storageArea].set({ [key]: value ?? null }); - }, - async setItems(items) { - checkPermission(); - const map = items.reduce>((map, item) => { - map[item.key] = item.value ?? null; - return map; - }, {}); - await browser.storage[opts.storageArea].set(map); - }, - async removeItem(key) { - checkPermission(); - await browser.storage[opts.storageArea].remove(key); - }, - async getKeys() { - checkPermission(); - const all = await browser.storage[opts.storageArea].get(); - return Object.keys(all); - }, - async clear() { - checkPermission(); - await browser.storage[opts.storageArea].clear(); - }, - watch(callback) { - checkPermission(); - _listeners.add(callback); - if (_listeners.size === 1) { - browser.storage[opts.storageArea].onChanged.addListener( - _storageListener, + }); + await Promise.all( + Array.from(areaToKeyValueMap.entries()).map( + async ([driverArea, values]) => { + const driver = getDriver(driverArea); + await driver.setItems(values); + }, + ), + ); + }, + setMeta: async (key, properties) => { + const { driver, driverKey } = resolveKey(key); + await setMeta(driver, driverKey, properties); + }, + removeItem: async (key, opts) => { + const { driver, driverKey } = resolveKey(key); + await removeItem(driver, driverKey, opts); + }, + removeItems: async (keys) => { + const areaToKeysMap = new Map(); + keys.forEach((key) => { + let keyStr: string; + let opts: RemoveItemOptions | undefined; + if (typeof key === 'string') { + keyStr = key; + } else { + keyStr = key.key; + opts = key.options; + } + const { driverArea, driverKey } = resolveKey(keyStr); + const areaKeys = areaToKeysMap.get(driverArea) ?? []; + areaKeys.push(driverKey); + if (opts?.removeMeta) { + areaKeys.push(getMetaKey(driverKey)); + } + areaToKeysMap.set(driverArea, areaKeys); + }); + + await Promise.all( + Array.from(areaToKeysMap.entries()).map(async ([driverArea, keys]) => { + const driver = getDriver(driverArea); + await driver.removeItems(keys); + }), + ); + }, + removeMeta: async (key, properties) => { + const { driver, driverKey } = resolveKey(key); + await removeMeta(driver, driverKey, properties); + }, + snapshot: async (base, opts) => { + const driver = getDriver(base); + const data = await driver.snapshot(); + opts?.excludeKeys?.forEach((key) => { + delete data[key]; + delete data[getMetaKey(key)]; + }); + return data; + }, + restoreSnapshot: async (base, data) => { + const driver = getDriver(base); + await driver.restoreSnapshot(data); + }, + watch: (key, cb) => { + const { driver, driverKey } = resolveKey(key); + return watch(driver, driverKey, cb); + }, + unwatch() { + Object.values(drivers).forEach((driver) => { + driver.unwatch(); + }); + }, + defineItem: (key, opts) => { + const { driver, driverKey } = resolveKey(key); + + const { version: targetVersion = 1, migrations = {} } = opts ?? {}; + if (targetVersion < 1) { + throw Error( + 'Storage item version cannot be less than 1. Initial versions should be set to 1, not 0.', + ); + } + const runMigrations = async () => { + const [value, meta] = await Promise.all([ + // TODO: Optimize with getItems + getItem(driver, driverKey, undefined), + getMeta(driver, driverKey), + ]); + if (value == null) return; + + const currentVersion = meta.v ?? 1; + if (currentVersion > targetVersion) { + throw Error( + `[wxt/storage] Migration ignored for "${key}", version downgrade detected (${currentVersion} -> ${targetVersion})`, ); } - return () => { - _listeners.delete(callback); - if (_listeners.size === 0) { - browser.storage[opts.storageArea].onChanged.removeListener( - _storageListener, - ); - } - }; - }, - }; - }); + const migrationsToRun = Array.from( + { length: targetVersion - currentVersion }, + (_, i) => currentVersion + i + 1, + ); + let migratedValue = value; + for (const migrateToVersion of migrationsToRun) { + migratedValue = + (await migrations?.[migrateToVersion]?.(migratedValue)) ?? + migratedValue; + } + await Promise.all([ + // TODO: Optimize with `setItem` + setItem(driver, driverKey, migratedValue), + setMeta(driver, driverKey, { v: targetVersion }), + ]); + }; + let _migrationsCompleted = runMigrations(); -function createWebExtensionStorage() { - const storage = createStorage(); - storage.mount('local', webExtensionDriver({ storageArea: 'local' })); - storage.mount('session', webExtensionDriver({ storageArea: 'session' })); - storage.mount('sync', webExtensionDriver({ storageArea: 'sync' })); - storage.mount('managed', webExtensionDriver({ storageArea: 'managed' })); + return { + _migrationsCompleted, + getValue: () => getItem(driver, driverKey, opts), + getMeta: () => getMeta(driver, driverKey), + setValue: (value) => setItem(driver, driverKey, value), + setMeta: (properties) => setMeta(driver, driverKey, properties), + removeValue: (opts) => removeItem(driver, driverKey, opts), + removeMeta: (properties) => removeMeta(driver, driverKey, properties), + watch: (cb) => watch(driver, driverKey, cb), + }; + }, + }; return storage; } -export type StorageValue = null | string | number | boolean | object; +function createDriver( + storageArea: 'local' | 'session' | 'sync' | 'managed', +): WxtStorageDriver { + const getStorageArea = () => { + if (browser.storage == null) + throw Error( + "You must add the 'storage' permission to your manifest to use 'wxt/storage'", + ); -export const storage: Storage = createWebExtensionStorage(); + return browser.storage[storageArea]; + }; + const watchListeners = new Set< + (changes: Storage.StorageAreaOnChangedChangesType) => void + >(); + return { + getItem: async (key) => { + const res = await getStorageArea().get(key); + return res[key]; + }, + getItems: async (keys) => { + const result = await getStorageArea().get(keys); + return keys.map((key) => ({ key, value: result[key] ?? null })); + }, + setItem: async (key, value) => { + if (value == null) { + await getStorageArea().remove(key); + } else { + await getStorageArea().set({ [key]: value }); + } + }, + setItems: async (values) => { + const map = values.reduce>( + (map, { key, value }) => { + map[key] = value; + return map; + }, + {}, + ); + await getStorageArea().set(map); + }, + removeItem: async (key) => { + await getStorageArea().remove(key); + }, + removeItems: async (keys) => { + await getStorageArea().remove(keys); + }, + snapshot: async () => { + return await getStorageArea().get(); + }, + restoreSnapshot: async (data) => { + await getStorageArea().set(data); + }, + watch(key, cb) { + const listener = (changes: Storage.StorageAreaOnChangedChangesType) => { + const change = changes[key]; + if (change == null) return; + if (dequal(change.newValue, change.oldValue)) return; + cb(change.newValue ?? null, change.oldValue ?? null); + }; + getStorageArea().onChanged.addListener(listener); + watchListeners.add(listener); + return () => { + getStorageArea().onChanged.removeListener(listener); + watchListeners.delete(listener); + }; + }, + unwatch() { + watchListeners.forEach((listener) => { + getStorageArea().onChanged.removeListener(listener); + }); + watchListeners.clear(); + }, + }; +} -export * from 'unstorage'; +export interface WxtStorage { + /** + * Get an item from storage, or return `null` if it doesn't exist. + * + * @example + * await storage.getItem("local:installDate"); + */ + getItem(key: string, opts?: GetItemOptions): Promise; + /** + * Get multiple items from storage. There is no guarentee of order in the returned array. + * + * @example + * await storage.getItems(["local:installDate", "session:someCounter"]); + */ + getItems( + keys: Array }>, + ): Promise>; + /** + * Return an object containing metadata about the key. Object is stored at `key + "$"`. If value + * is not an object, it returns an empty object. + * + * @example + * await storage.getMeta("local:installDate"); + */ + getMeta>(key: string): Promise; + /** + * Set a value in storage. Setting a value to `null` or `undefined` is equivalent to calling + * `removeItem`. + * + * @example + * await storage.setItem("local:installDate", Date.now()); + */ + setItem(key: string, value: T | null): Promise; + /** + * Set multiple values in storage. If a value is set to `null` or `undefined`, the key is removed. + * + * @example + * await storage.setItem([ + * { key: "local:installDate", value: Date.now() }, + * { key: "session:someCounter, value: 5 }, + * ]); + */ + setItems(values: Array<{ key: string; value: any }>): Promise; + /** + * Sets metadata properties. If some properties are already set, but are not included in the + * `properties` parameter, they will not be removed. + * + * @example + * await storage.setMeta("local:installDate", { appVersion }); + */ + setMeta>( + key: string, + properties: T | null, + ): Promise; + /** + * Removes an item from storage. + * + * @example + * await storage.removeItem("local:installDate"); + */ + removeItem(key: string, opts?: RemoveItemOptions): Promise; + /** + * Remove a list of keys from storage. + */ + removeItems( + keys: Array, + ): Promise; + /** + * Remove the entire metadata for a key, or specific properties by name. + * + * @example + * // Remove all metadata properties from the item + * await storage.removeMeta("local:installDate"); + * + * // Remove only specific the "v" field + * await storage.removeMeta("local:installDate", "v") + */ + removeMeta(key: string, properties?: string | string[]): Promise; + /** + * Return all the items in storage. + */ + snapshot( + base: string, + opts?: SnapshotOptions, + ): Promise>; + /** + * Restores the results of `snapshot`. If new properties have been saved since the snapshot, they are + * not overridden. Only values existing in the snapshot are overritten. + */ + restoreSnapshot(base: string, data: any): Promise; + /** + * Watch for changes to a specific key in storage. + */ + watch(key: string, cb: WatchCallback): Unwatch; + /** + * Remove all watch listeners. + */ + unwatch(): void; + /** + * Define a constant with utilities for reading/writing to a single value in storage. + * + * @example + * export const installDate = storage.defineItem("local:installDate"); + */ + defineItem = {}>( + key: string, + options?: WxtStorageItemOptions, + ): WxtStorageItem; +} + +interface WxtStorageDriver { + getItem(key: string): Promise; + getItems(keys: string[]): Promise<{ key: string; value: any }[]>; + setItem(key: string, value: T | null): Promise; + setItems(values: Array<{ key: string; value: any }>): Promise; + removeItem(key: string): Promise; + removeItems(keys: string[]): Promise; + snapshot(): Promise>; + restoreSnapshot(data: Record): Promise; + watch(key: string, cb: WatchCallback): Unwatch; + unwatch(): void; +} + +export interface WxtStorageItem< + TValue, + TMetadata extends Record, +> { + /** + * Get the latest value from storage. + */ + getValue(): Promise; + /** + * Get metadata. + */ + getMeta(): Promise>; + /** + * Set the value in storage. + */ + setValue(value: TValue | null): Promise; + /** + * Set metadata properties. + */ + setMeta(properties: NullablePartial): Promise; + /** + * Remove the value from storage. + */ + removeValue(opts?: RemoveItemOptions): Promise; + /** + * Remove all metadata or certain properties from metadata. + */ + removeMeta(properties?: string[]): Promise; + /** + * Listen for changes to the value in storage. + */ + watch(cb: WatchCallback): Unwatch; +} + +export interface GetItemOptions { + /** + * Value returned from `getValue` when it would otherwise return null. + */ + defaultValue?: T; +} + +export interface RemoveItemOptions { + /** + * Optionally remove metadata when deleting a key. + * + * @default false + */ + removeMeta?: boolean; +} + +export interface SnapshotOptions { + /** + * Exclude a list of keys. The storage area prefix should be removed since the snapshot is for a + * specific storage area already. + */ + excludeKeys?: string[]; +} + +export interface WxtStorageItemOptions extends GetItemOptions { + /** + * Provide a version number for the storage item to enable migrations. When changing the version + * in the future, migration functions will be ran on application startup. + */ + version?: number; + /** + * A map of version numbers to the functions used to migrate the data to that version. + */ + migrations?: Record any>; +} + +/** + * Same as `Partial`, but includes `| null`. It makes all the properties of an object optional and + * nullable. + */ +export type NullablePartial = { + [key in keyof T]+?: T[key] | undefined | null; +}; +/** + * Callback called when a value in storage is changed. + */ +export type WatchCallback = (newValue: T | null, oldValue: T | null) => void; +/** + * Call to remove a watch listener + */ +export type Unwatch = () => void;