diff --git a/.github/workflows/esm-lint.yml b/.github/workflows/esm-lint.yml index 857d5a5..1986d31 100644 --- a/.github/workflows/esm-lint.yml +++ b/.github/workflows/esm-lint.yml @@ -1,5 +1,5 @@ env: - IMPORT_TEXT: import storageCache from + IMPORT_TEXT: import {CachedValue, CachedFunction, globalCache} from NPM_MODULE_NAME: webext-storage-cache # FILE GENERATED WITH: npx ghat fregante/ghatemplates/esm-lint @@ -74,13 +74,15 @@ jobs: - run: npm install ./artifact - run: npx esbuild --bundle index.js TypeScript: + if: false runs-on: ubuntu-latest needs: Pack steps: - uses: actions/download-artifact@v3 - - run: npm install ./artifact - - run: echo "${{ env.IMPORT_TEXT }} '${{ env.NPM_MODULE_NAME }}'" > index.ts - - run: tsc index.ts + - run: npm install ./artifact @sindresorhus/tsconfig + - run: echo "${{ env.IMPORT_TEXT }} '${{ env.NPM_MODULE_NAME }}'" > index.mts + - run: echo '{"extends":"@sindresorhus/tsconfig","files":["index.mts"]}' > tsconfig.json + - run: tsc - run: cat index.js Node: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 44c0175..7d8022d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,4 @@ Thumbs.db *.bak *.log logs -*.map -/index.js -/index.test-d.js -*.d.ts +distribution diff --git a/index.test-d.ts b/index.test-d.ts deleted file mode 100644 index 207e1cb..0000000 --- a/index.test-d.ts +++ /dev/null @@ -1,67 +0,0 @@ -import {expectType, expectNotAssignable, expectAssignable} from 'tsd'; -import cache from './index.js'; - -type Primitive = boolean | number | string; -type Value = Primitive | Primitive[] | Record; - -expectType>(cache.has('key')); -expectType>(cache.delete('key')); - -expectType>(cache.get('key')); -expectType>(cache.get('key')); -expectNotAssignable>(cache.get('key')); - -expectAssignable>(cache.set('key', 1)); -expectAssignable>(cache.set('key', true)); -expectAssignable>(cache.set('key', [true, 'string'])); -expectAssignable>>(cache.set('key', {wow: [true, 'string']})); -expectAssignable>(cache.set('key', 1, {days: 1})); - -const cachedPower = cache.function('power', async (n: number) => n ** 1000); -expectType<((n: number) => Promise) & {fresh: (n: number) => Promise}>(cachedPower); -expectType(await cachedPower(1)); - -expectType<((n: string) => Promise) & {fresh: (n: string) => Promise}>( - cache.function('number', async (n: string) => Number(n)), -); - -async function identity(x: string): Promise; -async function identity(x: number): Promise; -async function identity(x: number | string): Promise { - return x; -} - -expectType>(cache.function('identity', identity)(1)); -expectType>(cache.function('identity', identity)('1')); -expectNotAssignable>(cache.function('identity', identity)(1)); -expectNotAssignable>(cache.function('identity', identity)('1')); - -expectType<((n: string) => Promise) & {fresh: (n: string) => Promise}>( - cache.function('number', async (n: string) => Number(n), { - maxAge: {days: 20}, - }), -); - -expectType<((n: string) => Promise) & {fresh: (n: string) => Promise}>( - cache.function('number', async (n: string) => Number(n), { - maxAge: {days: 20}, - staleWhileRevalidate: {days: 5}, - }), -); - -expectType<((date: Date) => Promise) & {fresh: (date: Date) => Promise}>( - cache.function('number', async (date: Date) => String(date.getHours()), { - cacheKey: ([date]) => date.toLocaleString(), - }), -); - -expectType<((date: Date) => Promise) & {fresh: (date: Date) => Promise}>( - cache.function('number', async (date: Date) => String(date.getHours()), { - shouldRevalidate: date => typeof date === 'string', - }), -); - -// This function won't be cached -expectType<((n: undefined[]) => Promise) & {fresh: (n: undefined[]) => Promise}>( - cache.function('first', async (n: undefined[]) => n[1]), -); diff --git a/index.test.js b/index.test.js deleted file mode 100644 index 37cc033..0000000 --- a/index.test.js +++ /dev/null @@ -1,369 +0,0 @@ -import nodeAssert from 'node:assert'; -import {test, beforeEach, vi, assert, expect} from 'vitest'; -import toMilliseconds from '@sindresorhus/to-milliseconds'; -import cache from './index.js'; - -// Help migration away from AVA -const t = { - is: assert.equal, - not: assert.notEqual, - true: assert.ok, - deepEqual: assert.deepEqual, - throwsAsync: nodeAssert.rejects, -}; - -const getUsernameDemo = async name => name.slice(1).toUpperCase(); - -function timeInTheFuture(time) { - return Date.now() + toMilliseconds(time); -} - -function createCache(daysFromToday, wholeCache) { - for (const [key, data] of Object.entries(wholeCache)) { - chrome.storage.local.get - .withArgs(key) - .yields({[key]: { - data, - maxAge: timeInTheFuture({days: daysFromToday}), - }}); - } -} - -beforeEach(() => { - chrome.flush(); - chrome.storage.local.get.yields({}); - chrome.storage.local.set.yields(undefined); - chrome.storage.local.remove.yields(undefined); -}); - -test('get() with empty cache', async () => { - t.is(await cache.get('name'), undefined); -}); - -test('get() with cache', async () => { - createCache(10, { - 'cache:name': 'Rico', - }); - t.is(await cache.get('name'), 'Rico'); -}); - -test('get() with expired cache', async () => { - createCache(-10, { - 'cache:name': 'Rico', - }); - t.is(await cache.get('name'), undefined); -}); - -test('has() with empty cache', async () => { - t.is(await cache.has('name'), false); -}); - -test('has() with cache', async () => { - createCache(10, { - 'cache:name': 'Rico', - }); - t.is(await cache.has('name'), true); -}); - -test('has() with expired cache', async () => { - createCache(-10, { - 'cache:name': 'Rico', - }); - t.is(await cache.has('name'), false); -}); - -test('set() without a value', async () => { - await t.throwsAsync(cache.set('name'), { - name: 'TypeError', - message: 'Expected a value as the second argument', - }); -}); - -test('set() with undefined', async () => { - await cache.set('name', 'Anne'); - await cache.set('name', undefined); - // Cached value should be erased - t.is(await cache.has('name'), false); -}); - -test('set() with value', async () => { - const maxAge = 20; - await cache.set('name', 'Anne', {days: maxAge}); - const arguments_ = chrome.storage.local.set.lastCall.args[0]; - t.deepEqual(Object.keys(arguments_), ['cache:name']); - t.is(arguments_['cache:name'].data, 'Anne'); - t.true(arguments_['cache:name'].maxAge > timeInTheFuture({days: maxAge - 0.5})); - t.true(arguments_['cache:name'].maxAge < timeInTheFuture({days: maxAge + 0.5})); -}); - -test('function() with empty cache', async () => { - const spy = vi.fn(getUsernameDemo); - const call = cache.function('spy', spy); - - t.is(await call('@anne'), 'ANNE'); - - t.is(chrome.storage.local.get.lastCall.args[0], 'cache:spy:@anne'); - expect(spy).toHaveBeenNthCalledWith(1, '@anne'); - t.is(chrome.storage.local.set.lastCall.args[0]['cache:spy:@anne'].data, 'ANNE'); -}); - -test('function() with cache', async () => { - createCache(10, { - 'cache:spy:@anne': 'ANNE', - }); - - const spy = vi.fn(getUsernameDemo); - const call = cache.function('spy', spy); - - t.is(await call('@anne'), 'ANNE'); - - t.is(chrome.storage.local.get.lastCall.args[0], 'cache:spy:@anne'); - t.is(chrome.storage.local.set.callCount, 0); - expect(spy).not.toHaveBeenCalled(); -}); - -test('function() with expired cache', async () => { - createCache(-10, { - 'cache:spy:@anne': 'ONNA', - }); - - const spy = vi.fn(getUsernameDemo); - const call = cache.function('spy', spy); - - t.is(await cache.get('@anne'), undefined); - t.is(await call('@anne'), 'ANNE'); - t.is(chrome.storage.local.get.lastCall.args[0], 'cache:spy:@anne'); - expect(spy).toHaveBeenNthCalledWith(1, '@anne'); - t.is(chrome.storage.local.set.lastCall.args[0]['cache:spy:@anne'].data, 'ANNE'); -}); - -test('function() with empty cache and staleWhileRevalidate', async () => { - const maxAge = 1; - const staleWhileRevalidate = 29; - - const spy = vi.fn(getUsernameDemo); - const call = cache.function('spy', spy, { - maxAge: {days: maxAge}, - staleWhileRevalidate: {days: staleWhileRevalidate}, - }); - - t.is(await call('@anne'), 'ANNE'); - - t.is(chrome.storage.local.get.lastCall.args[0], 'cache:spy:@anne'); - t.is(chrome.storage.local.set.callCount, 1); - const arguments_ = chrome.storage.local.set.lastCall.args[0]; - t.deepEqual(Object.keys(arguments_), ['cache:spy:@anne']); - t.is(arguments_['cache:spy:@anne'].data, 'ANNE'); - - const expectedExpiration = maxAge + staleWhileRevalidate; - t.true(arguments_['cache:spy:@anne'].maxAge > timeInTheFuture({days: expectedExpiration - 0.5})); - t.true(arguments_['cache:spy:@anne'].maxAge < timeInTheFuture({days: expectedExpiration + 0.5})); -}); - -test('function() with fresh cache and staleWhileRevalidate', async () => { - createCache(30, { - 'cache:spy:@anne': 'ANNE', - }); - - const spy = vi.fn(getUsernameDemo); - const call = cache.function('spy', spy, { - maxAge: {days: 1}, - staleWhileRevalidate: {days: 29}, - }); - - t.is(await call('@anne'), 'ANNE'); - - // Cache is still fresh, it should be used - expect(spy).not.toHaveBeenCalled(); - t.is(chrome.storage.local.set.callCount, 0); - - await new Promise(resolve => { - setTimeout(resolve, 100); - }); - - // Cache is still fresh, it should never be revalidated - expect(spy).not.toHaveBeenCalled(); -}); - -test('function() with stale cache and staleWhileRevalidate', async () => { - createCache(15, { - 'cache:spy:@anne': 'ANNE', - }); - - const spy = vi.fn(getUsernameDemo); - const call = cache.function('spy', spy, { - maxAge: {days: 1}, - staleWhileRevalidate: {days: 29}, - }); - - t.is(await call('@anne'), 'ANNE'); - - t.is(chrome.storage.local.get.lastCall.args[0], 'cache:spy:@anne'); - t.is(chrome.storage.local.set.callCount, 0); - - // It shouldn’t be called yet - expect(spy).not.toHaveBeenCalled(); - - await new Promise(resolve => { - setTimeout(resolve, 100); - }); - - // It should be revalidated - expect(spy).toHaveBeenCalledOnce(); - t.is(chrome.storage.local.set.callCount, 1); - t.is(chrome.storage.local.set.lastCall.args[0]['cache:spy:@anne'].data, 'ANNE'); -}); - -test('function() varies cache by function argument', async () => { - createCache(10, { - 'cache:spy:@anne': 'ANNE', - }); - - const spy = vi.fn(getUsernameDemo); - const call = cache.function('spy', spy); - - t.is(await call('@anne'), 'ANNE'); - expect(spy).not.toHaveBeenCalled(); - - t.is(await call('@mari'), 'MARI'); - expect(spy).toHaveBeenCalledOnce(); -}); - -test('function() accepts custom cache key generator', async () => { - createCache(10, { - 'cache:spy:@anne,1': 'ANNE,1', - }); - - const spy = vi.fn(getUsernameDemo); - const call = cache.function('spy', spy); - - await call('@anne', '1'); - expect(spy).not.toHaveBeenCalled(); - - await call('@anne', '2'); - expect(spy).toHaveBeenCalledOnce(); - - t.is(chrome.storage.local.get.firstCall.args[0], 'cache:spy:@anne,1'); - t.is(chrome.storage.local.get.lastCall.args[0], 'cache:spy:@anne,2'); -}); - -test('function() accepts custom string-based cache key', async () => { - createCache(10, { - 'cache:CUSTOM:["@anne",1]': 'ANNE,1', - }); - - const spy = vi.fn(getUsernameDemo); - const call = cache.function('CUSTOM', spy); - - await call('@anne', 1); - expect(spy).not.toHaveBeenCalled(); - - await call('@anne', 2); - expect(spy).toHaveBeenCalledOnce(); - - t.is(chrome.storage.local.get.firstCall.args[0], 'cache:CUSTOM:["@anne",1]'); - t.is(chrome.storage.local.get.lastCall.args[0], 'cache:CUSTOM:["@anne",2]'); -}); - -test('function() accepts custom string-based with non-primitive parameters', async () => { - createCache(10, { - 'cache:CUSTOM:["@anne",{"user":[1]}]': 'ANNE,1', - }); - - const spy = vi.fn(getUsernameDemo); - const call = cache.function('CUSTOM', spy); - - await call('@anne', {user: [1]}); - expect(spy).not.toHaveBeenCalled(); - - await call('@anne', {user: [2]}); - expect(spy).toHaveBeenCalledOnce(); - - t.is(chrome.storage.local.get.firstCall.args[0], 'cache:CUSTOM:["@anne",{"user":[1]}]'); - t.is(chrome.storage.local.get.lastCall.args[0], 'cache:CUSTOM:["@anne",{"user":[2]}]'); -}); - -test('function() verifies cache with shouldRevalidate callback', async () => { - createCache(10, { - 'cache:@anne': 'anne@', - }); - - const spy = vi.fn(getUsernameDemo); - const call = cache.function('spy', spy, { - shouldRevalidate: value => value.endsWith('@'), - }); - - t.is(await call('@anne'), 'ANNE'); - t.is(chrome.storage.local.get.lastCall.args[0], 'cache:spy:@anne'); - t.is(chrome.storage.local.set.lastCall.args[0]['cache:spy:@anne'].data, 'ANNE'); - expect(spy).toHaveBeenCalledOnce(); -}); - -test('function() avoids concurrent function calls', async () => { - const spy = vi.fn(getUsernameDemo); - const call = cache.function('spy', spy); - - expect(spy).not.toHaveBeenCalled(); - t.is(call('@anne'), call('@anne')); - await call('@anne'); - expect(spy).toHaveBeenCalledOnce(); - - t.not(call('@new'), call('@other')); - await call('@idk'); - expect(spy).toHaveBeenCalledTimes(4); -}); - -test('function() avoids concurrent function calls with complex arguments via cacheKey', async () => { - const spy = vi.fn(async (transform, user) => transform(user.name)); - const call = cache.function('spy', spy, { - cacheKey: ([fn, user]) => JSON.stringify([fn.name, user]), - }); - - expect(spy).not.toHaveBeenCalled(); - const cacheMePlease = name => name.slice(1).toUpperCase(); - t.is(call(cacheMePlease, {name: '@anne'}), call(cacheMePlease, {name: '@anne'})); - await call(cacheMePlease, {name: '@anne'}); - expect(spy).toHaveBeenCalledOnce(); - - t.not(call(cacheMePlease, {name: '@new'}), call(cacheMePlease, {name: '@other'})); - await call(cacheMePlease, {name: '@idk'}); - expect(spy).toHaveBeenCalledTimes(4); -}); - -test('function() always loads the data from storage, not memory', async () => { - createCache(10, { - 'cache:spy:@anne': 'ANNE', - }); - - const spy = vi.fn(getUsernameDemo); - const call = cache.function('spy', spy); - - t.is(await call('@anne'), 'ANNE'); - - t.is(chrome.storage.local.get.callCount, 1); - t.is(chrome.storage.local.get.lastCall.args[0], 'cache:spy:@anne'); - - createCache(10, { - 'cache:spy:@anne': 'NEW ANNE', - }); - - t.is(await call('@anne'), 'NEW ANNE'); - - t.is(chrome.storage.local.get.callCount, 2); - t.is(chrome.storage.local.get.lastCall.args[0], 'cache:spy:@anne'); -}); - -test('function.fresh() ignores cached value', async () => { - createCache(10, { - 'cache:spy:@anne': 'OVERWRITE_ME', - }); - - const spy = vi.fn(getUsernameDemo); - const call = cache.function('spy', spy); - - t.is(await call.fresh('@anne'), 'ANNE'); - - expect(spy).toHaveBeenNthCalledWith(1, '@anne'); - t.is(chrome.storage.local.get.callCount, 0); - t.is(chrome.storage.local.set.lastCall.args[0]['cache:spy:@anne'].data, 'ANNE'); -}); diff --git a/package.json b/package.json index ab27f0a..261314b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "webext-storage-cache", - "version": "6.0.0-2", - "description": "Map-like promised cache storage with expiration. WebExtensions module for Chrome, Firefox, Safari", + "version": "6.0.0-5", + "description": "Cache values in your Web Extension and clear them on expiration. Also includes a memoize-like API to cache any function results automatically.", "keywords": [ "await", "background page", @@ -12,7 +12,9 @@ "expirating", "extension", "firefox", - "map", + "safari", + "memoize", + "memoization", "options page", "promises", "self-cleaning", @@ -28,10 +30,20 @@ "Connor Love" ], "type": "module", - "exports": "./index.js", + "exports": { + ".": "./distribution/index.js", + "./legacy.js": "./distribution/legacy.js" + }, + "types": "./distribution/index.d.ts", "files": [ - "index.js", - "index.d.ts" + "distribution/index.js", + "distribution/index.d.ts", + "distribution/legacy.js", + "distribution/legacy.d.ts", + "distribution/cached-value.js", + "distribution/cached-value.d.ts", + "distribution/cached-function.js", + "distribution/cached-function.d.ts" ], "scripts": { "build": "tsc", @@ -47,6 +59,7 @@ }, "dependencies": { "@sindresorhus/to-milliseconds": "^2.0.0", + "type-fest": "^3.11.0", "webext-detect-page": "^4.1.0", "webext-polyfill-kinda": "^1.0.2" }, @@ -59,5 +72,11 @@ "typescript": "^5.0.4", "vitest": "^0.31.1", "xo": "^0.54.2" + }, + "engines": { + "node": ">=18" + }, + "tsd": { + "directory": "source" } } diff --git a/readme.md b/readme.md index f970de5..f95c027 100644 --- a/readme.md +++ b/readme.md @@ -1,12 +1,17 @@ # webext-storage-cache [![](https://img.shields.io/npm/v/webext-storage-cache.svg)](https://www.npmjs.com/package/webext-storage-cache) -> Map-like promised cache storage with expiration. WebExtensions module for Chrome, Firefox, Safari +> Cache values in your Web Extension and clear them on expiration. Also includes a memoize-like API to cache any function results automatically. -This module works on content scripts, background pages and option pages. +- Browsers: Chrome, Firefox, and Safari +- Manifest: v2 and v3 +- Context: They can be called from any context that has access to the `chrome.storage` APIs +- Permissions: (with attached "reasons" for submission to the Chrome Web Store) + - `storage`: "The extension caches some values into the local storage" + - `alarms`: "The extension automatically clears its expired storage at certain intervals" ## Install -You can download the [standalone bundle](https://bundle.fregante.com/?pkg=webext-additional-permissions&global=getAdditionalPermissions) and include it in your `manifest.json`. +You can download the [standalone bundle](https://bundle.fregante.com/?pkg=webext-storage-cache&global=window) and include it in your `manifest.json`. Or use `npm`: @@ -14,11 +19,6 @@ Or use `npm`: npm install webext-storage-cache ``` -```js -// This module is only offered as a ES Module -import storageCache from 'webext-storage-cache'; -``` - ## Usage This module requires the `storage` permission and it’s suggested to also use `alarms` to safely schedule cache purging: @@ -40,27 +40,31 @@ This module requires the `storage` permission and it’s suggested to also use ` ``` ```js -import cache from 'webext-storage-cache'; +import {CachedValue} from 'webext-storage-cache'; + +const item = new CachedValue('unique', { + maxAge: { + days: 3, + }, +}); (async () => { - if (!(await cache.has('unique'))) { + if (!(await item.isCached())) { const cachableItem = await someFunction(); - await cache.set('unique', cachableItem, { - days: 3, - }); + await item.set(cachableItem); } - console.log(await cache.get('unique')); + console.log(await item.get()); })(); ``` -The same code could also be written more effectively with `cache.function`: +The same code could also be written more effectively with `CachedFunction`: ```js -import cache from 'webext-storage-cache'; +import {CachedFunction} from 'webext-storage-cache'; -const cachedFunction = cache.function(someFunction, { - name: 'unique', +const item = new CachedValue('unique', { + updater: someFunction, maxAge: { days: 3, }, @@ -73,202 +77,43 @@ const cachedFunction = cache.function(someFunction, { ## API -Similar to a `Map()`, but **all methods a return a `Promise`.** It also has a memoization method that hides the caching logic and makes it a breeze to use. +- [CachedValue](./source/cached-value.md) - A simple API getter/setter +- [CachedFunction](./source/cached-function.md) - A memoize-like API to cache your function calls without manually calling `isCached`/`get`/`set` +- `globalCache` - Global helpers, documented below +- `legacy` - The previous Map-like API, documented below, deprecated -### cache.has(key) +### globalCache.clear() -Checks if the given key is in the cache, returns a `boolean`. +Clears the cache. This is a special method that acts on the entire cache of the extension. ```js -const isCached = await cache.has('cached-url'); -// true or false -``` - -#### key - -Type: `string` - -### cache.get(key) +import {globalCache} from 'webext-storage-cache'; -Returns the cached value of key if it exists and hasn't expired, returns `undefined` otherwise. - -```js -const url = await cache.get('cached-url'); -// It will be `undefined` if it's not found. +document.querySelector('.options .clear-cache').addEventListener('click', async () => { + await globalCache.clear() +}) ``` -#### key - -Type: `string` - -### cache.set(key, value, maxAge) - -Caches the given key and value for a given amount of time. It returns the value itself. - -```js -const info = await getInfoObject(); -await cache.set('core-info', info); // Cached for 30 days by default -``` - -#### key - -Type: `string` - -#### value +### legacy API -Type: `string | number | boolean` or `array | object` of those three types. +The API used until v5 has been deprecated and you should migrate to: -`undefined` will remove the cached item. For this purpose it's best to use `cache.delete(key)` instead +- `CachedValue` for simple `cache.get`/`cache.set` calls. This API makes more sense in a typed context because the type is preserved/enforced across calls. +- `CachedFunction` for `cache.function`. It behaves in a similar fashion, but it also has extra methods like `getCached` and `getFresh` -#### maxAge +You can: -Type: [`TimeDescriptor`](https://github.com/sindresorhus/to-milliseconds#input)
-Default: `{days: 30}` - -The amount of time after which the cache item will expire. - -### cache.delete(key) - -Deletes the requested item from the cache. - -```js -await cache.delete('cached-url'); -``` - -#### key - -Type: `string` - -### cache.clear() - -Deletes the entire cache. - -```js -await cache.clear(); -``` - -### cache.function(getter, options) - -Caches the return value of the function based on the `cacheKey`. It works similarly to a memoization function: +- [Migrate from v5 to v6](https://github.com/fregante/webext-storage-cache/releases/v6.0.0), or +- Keep using the legacy API (except `cache.function`) by importing `webext-storage-cache/legacy.js` (until v7 is published) ```js -async function getHTML(url, options) { - const response = await fetch(url, options); - return response.text(); -} - -const cachedGetHTML = cache.function(getHTML, {name: 'html'}); +import cache from "webext-storage-cache/legacy.js"; -const html = await cachedGetHTML('https://google.com', {}); -// The HTML of google.com will be saved with the key 'https://google.com' - -const freshHtml = await cachedGetHTML.fresh('https://google.com', {}); -// Escape hatch to ignore memoization and force a refresh of the cache +await cache.get('my-url'); +await cache.set('my-url', 'https://example.com'); ``` -#### getter - -Type: `async function` that returns a cacheable value. - -Returning `undefined` will skip the cache, just like `cache.set()`. - -#### options - -##### name - -Type: `string` - -Required. - -The base name used to construct the key in the cache: - -```js -const cachedOperate = cache.function(operate, {name: 'operate'}); - -cachedOperate(1, 2, 3); -// Its result will be stored in the key 'operate:[1,2,3]' -``` - -##### cacheKey - -Type: `(args: any[]) => string` -Default: `JSON.stringify` - -By default, the function’s `arguments` JSON-stringified array will be used to create the cache key. - -You can pass a `cacheKey` function to customize how the key is generated: - -```js -const cachedFetchPosts = cache.function(fetchPosts, { - name: 'fetchPosts', - cacheKey: (args) => args[0].id, // Use just the user ID -}); - -const user = {id: 123, name: 'Fregante'}; -cachedFetchPosts(user); -// Its result will be stored in the key 'fetchPosts:[1,2,3]' -``` - -##### maxAge - -Type: [`TimeDescriptor`](https://github.com/sindresorhus/to-milliseconds#input)
-Default: `{days: 30}` - -The amount of time after which the cache item will expire. - -##### staleWhileRevalidate - -Type: [`TimeDescriptor`](https://github.com/sindresorhus/to-milliseconds#input)
-Default: `{days: 0}` (disabled) - -Specifies how much longer an item should be kept in cache after its expiration. During this extra time, the item will still be served from cache instantly, but `getter` will be also called asynchronously to update the cache. A later call will return the updated and fresher item. - -```js -const cachedOperate = cache.function(operate, { - name: 'operate', - maxAge: { - days: 10, - }, - staleWhileRevalidate: { - days: 2, - }, -}); - -cachedOperate(); // It will run `operate` and cache it for 10 days -cachedOperate(); // It will return the cache - -/* 11 days later, cache is expired, but still there */ - -cachedOperate(); // It will return the cache -// Asynchronously, it will also run `operate` and cache the new value for 10 more days - -/* 13 days later, cache is expired and deleted */ - -cachedOperate(); // It will run `operate` and cache it for 10 days -``` - -##### shouldRevalidate - -Type: `function` that returns a boolean
-Default: `() => false` - -You may want to have additional checks on the cached value, for example after updating its format. - -```js -async function getContent(url) { - const response = await fetch(url); - return response.json(); // For example, you used to return plain text, now you return a JSON object -} - -const cachedGetContent = cache.function(getContent, { - name: 'getContent', - // If it's a string, it's in the old format and a new value will be fetched and cached - shouldRevalidate: cachedValue => typeof cachedValue === 'string', -}); - -const json = await cachedGetHTML('https://google.com'); -// The HTML of google.com will be saved with the key 'https://google.com' -``` +The documentation for the legacy API can be found on the [v5 version of this readme](https://github.com/fregante/webext-storage-cache/blob/v5.1.1/readme.md#api). ## Related diff --git a/source/cached-function.md b/source/cached-function.md new file mode 100644 index 0000000..5aaeefc --- /dev/null +++ b/source/cached-function.md @@ -0,0 +1,195 @@ +_Go back to the [main documentation page.](../readme.md#api)_ + +# CachedFunction(key, options) + +You can think of `CachedFunction` as an advanced "memoize" function that you can call with any arguments, but also: + +- verify whether a specific set of arguments is cached (`.isCached()`) +- only get the cached value if it exists (`.getCached()`) +- only get the fresh value, skipping the cache (but still caching the result) (`.getFresh()`) +- delete a cached value (`.delete()`) + +## key + +Type: string + +The unique name that will be used in `chrome.storage.local` combined with the function arguments, like `cache:${key}:{arguments}`. + +For example, these two calls: + +```js +const pages = new CachedFunction('pages', {updater: fetchText}); + +await pages.get(); +await pages.get('./contacts'); +await pages.get('./about', 2); +await pages.get(); // Will be retrieved from cache +``` + +Will call `fetchText` 3 times and create 3 items in the storage: + +```json +{ + "cache:pages": "You're on the homepage", + "cache:pages:[\"./contacts\"]": "You're on the contacts page", + "cache:pages:[\"./about\",2]": "You're on the about page" +} +``` + +## options + +### updater + +Required.
+Type: `async function` that returns a cacheable value. + +Returning `undefined` will delete the item from the cache. + +### maxAge + +Type: [`TimeDescriptor`](https://github.com/sindresorhus/to-milliseconds#input)
+Default: `{days: 30}` + +The amount of time after which the cache item will expire after being each cache update. + +### staleWhileRevalidate + +Type: [`TimeDescriptor`](https://github.com/sindresorhus/to-milliseconds#input)
+Default: `{days: 0}` (disabled) + +Specifies how much longer an item should be kept in cache after its expiration. During this extra time, the item will still be served from cache instantly, but `updater` will be also called asynchronously to update the cache. A later call will return the updated and fresher item. + +```js +const operate = new CachedFunction('posts', { + updater: operate, + maxAge: { + days: 10, + }, + staleWhileRevalidate: { + days: 2, + }, +}); + +await operate.get(); // It will run `operate` and cache it for 10 days +await operate.get(); // It will return the cache + +/* 3 days later, cache is expired, but still there */ + +await operate.get(); // It will return the cache +// Asynchronously, it will also run `operate` and cache the new value for 10 more days + +/* 13 days later, cache is expired and deleted */ + +await operate.get(); // It will run `operate` and cache it for 10 days +``` + +### shouldRevalidate + +Type: `(cachedValue) => boolean`
+Default: `() => false` + +You may want to have additional checks on the cached value, for example after updating its format. + +```js +async function getContent(url) { + const response = await fetch(url); + return response.json(); // For example, you used to return plain text, now you return a JSON object +} + +const content = new CachedFunction('content', { + updater: getContent, + + // If it's a string, it's in the old format and a new value will be fetched and cached + shouldRevalidate: cachedValue => typeof cachedValue === 'string', +}); + +const json = await content.get('https://google.com'); +// Even if it's cached as a regular string, the cache will be discarded and `getContent` will be called again +``` + +### cacheKey + +Type: `(args: any[]) => string` +Default: `JSON.stringify` + +By default, the function’s `arguments` JSON-stringified array will be used to create the cache key. + +```js +const posts = new CachedFunction('posts', {updater: fetchPosts}); +const user = {id: 123, name: 'Fregante'}; +await posts.get(user); +// Its result will be stored in the key 'cache:fetchPosts:[{"id":123,name:"Fregante"}]' +``` + +You can pass a `cacheKey` function to customize how the key is generated, saving storage and making it more sensible: + +```js +const posts = new CachedFunction('posts', { + updater: fetchPosts, + cacheKey: (args) => args[0].id, // ✅ Use only the user ID +}); + +const user = {id: 123, name: 'Fregante'}; +await posts.get(user); +// Its result will be stored in the key 'cache:fetchPosts:123' +``` + +## CachedFunction#get(...arguments) + +This method is equivalent to calling your `updater` function with the specified parameters, unless the result of a previous call is already in the cache: + +```js +const repositories = new CachedFunction('repositories', {updater: repoApi}); +await repositories.get('fregante', 'doma'); // Will call repoApi('fregante', 'doma') +await repositories.get('fregante', 'doma'); // Will return the item from the cache +await repositories.get('fregante', 'webext-base-css'); // Will call repoApi('fregante', 'webext-base-css') +``` + +## CachedFunction#getFresh(...arguments) + +This updates the cache just like `.get()`, except it always calls `updater` regardless of cache state. It's meant to be used as a "refresh cache" action: + +```js +const repositories = new CachedFunction('repositories', {updater: repoApi}); +await repositories.get('fregante', 'doma'); // Will call repoApi('fregante', 'doma') +await repositories.getFresh('fregante', 'doma'); // Will call repoApi('fregante', 'doma') regardless of cache state +``` + +## CachedFunction#getCached(...arguments) + +This only returns the value of a previous `.get()` call with the same arguemnts, but it never calls your `updater`: + +```js +const repositories = new CachedFunction('repositories', {updater: repoApi}); +await repositories.getCached('fregante', 'doma'); // It can be undefined +``` + + +## CachedFunction#isCached(...arguments) + +```js +const repositories = new CachedFunction('repositories', {updater: repoApi}); +await repositories.isCached('fregante', 'doma'); +// => true / false +``` + +## CachedFunction#delete(...arguments) + +```js +const repositories = new CachedFunction('repositories', {updater: repoApi}); +await repositories.delete('fregante', 'doma'); +``` + +## CachedFunction#applyOverride(arguments, newValue) + +This method should only be used if you want to override the cache with a custom value, but you should prefer `get` or `getFresh` instead, keeping the logic exclusively in your `updater` function. + +```js +const repositories = new CachedFunction('repositories', {updater: repoApi}); +// Will override the local cache for the `repoApi('fregante', 'doma')` call +await repositories.applyOverride(['fregante', 'doma'], {id: 134, lastUpdated: 199837738894}); +``` + +## License + +MIT © [Federico Brigante](https://fregante.com) diff --git a/source/cached-function.test-d.ts b/source/cached-function.test-d.ts new file mode 100644 index 0000000..116d34d --- /dev/null +++ b/source/cached-function.test-d.ts @@ -0,0 +1,46 @@ +/* eslint-disable no-new */ +import {expectType, expectNotAssignable, expectNotType} from 'tsd'; +import CachedFunction from './cached-function.js'; + +const itemWithUpdater = new CachedFunction('key', { + updater: async (one: number): Promise => String(one).toUpperCase(), +}); + +expectType<((n: number) => Promise)>(itemWithUpdater.get); +expectNotAssignable<((n: string) => Promise)>(itemWithUpdater.get); + +async function identity(x: string): Promise; +async function identity(x: number): Promise; +async function identity(x: number | string): Promise { + return x; +} + +expectType>(new CachedFunction('identity', {updater: identity}).get(1)); +expectType>(new CachedFunction('identity', {updater: identity}).get('1')); + +// @ts-expect-error -- If a function returns undefined, it's not cacheable +new CachedFunction('identity', {updater: async (n: undefined[]) => n[1]}); + +expectNotAssignable>(new CachedFunction('identity', {updater: identity}).get(1)); +expectNotType>(new CachedFunction('identity', {updater: identity}).get('1')); + +new CachedFunction('number', { + updater: async (n: string) => Number(n), + maxAge: {days: 20}, +}); + +new CachedFunction('number', { + updater: async (n: string) => Number(n), + maxAge: {days: 20}, + staleWhileRevalidate: {days: 5}, +}); + +new CachedFunction('number', { + updater: async (date: Date) => String(date.getHours()), + cacheKey: ([date]) => date.toLocaleString(), +}); + +new CachedFunction('number', { + updater: async (date: Date) => String(date.getHours()), + shouldRevalidate: date => typeof date === 'string', +}); diff --git a/source/cached-function.test.js b/source/cached-function.test.js new file mode 100644 index 0000000..dea01b3 --- /dev/null +++ b/source/cached-function.test.js @@ -0,0 +1,362 @@ +/* eslint-disable n/file-extension-in-import -- No alternative until this file is changed to .test.ts */ +import {test, beforeEach, vi, assert, expect} from 'vitest'; +import toMilliseconds from '@sindresorhus/to-milliseconds'; +import CachedFunction from './cached-function.ts'; + +const getUsernameDemo = async name => name.slice(1).toUpperCase(); + +function timeInTheFuture(time) { + return Date.now() + toMilliseconds(time); +} + +function createCache(daysFromToday, wholeCache) { + for (const [key, data] of Object.entries(wholeCache)) { + chrome.storage.local.get + .withArgs(key) + .yields({[key]: { + data, + maxAge: timeInTheFuture({days: daysFromToday}), + }}); + } +} + +beforeEach(() => { + chrome.flush(); + chrome.storage.local.get.yields({}); + chrome.storage.local.set.yields(undefined); + chrome.storage.local.remove.yields(undefined); +}); + +test('getCached() with empty cache', async () => { + const spy = vi.fn(getUsernameDemo); + const testItem = new CachedFunction('name', {updater: spy}); + assert.equal(await testItem.getCached(), undefined); + expect(spy).not.toHaveBeenCalled(); +}); + +test('getCached() with cache', async () => { + const spy = vi.fn(getUsernameDemo); + const testItem = new CachedFunction('name', {updater: spy}); + createCache(10, { + 'cache:name': 'Rico', + }); + assert.equal(await testItem.getCached(), 'Rico'); + expect(spy).not.toHaveBeenCalled(); +}); + +test('getCached() with expired cache', async () => { + const spy = vi.fn(getUsernameDemo); + const testItem = new CachedFunction('name', {updater: spy}); + createCache(-10, { + 'cache:name': 'Rico', + }); + assert.equal(await testItem.getCached(), undefined); + expect(spy).not.toHaveBeenCalled(); +}); + +test('`updater` with empty cache', async () => { + const spy = vi.fn(getUsernameDemo); + const updaterItem = new CachedFunction('spy', {updater: spy}); + + assert.equal(await updaterItem.get('@anne'), 'ANNE'); + + assert.equal(chrome.storage.local.get.lastCall.args[0], 'cache:spy:["@anne"]'); + expect(spy).toHaveBeenNthCalledWith(1, '@anne'); + assert.equal(chrome.storage.local.set.lastCall.args[0]['cache:spy:["@anne"]'].data, 'ANNE'); +}); + +test('`updater` with cache', async () => { + createCache(10, { + 'cache:spy:["@anne"]': 'ANNE', + }); + + const spy = vi.fn(getUsernameDemo); + const updaterItem = new CachedFunction('spy', {updater: spy}); + + assert.equal(await updaterItem.get('@anne'), 'ANNE'); + + assert.equal(chrome.storage.local.get.lastCall.args[0], 'cache:spy:["@anne"]'); + assert.equal(chrome.storage.local.set.callCount, 0); + expect(spy).not.toHaveBeenCalled(); +}); + +test('`updater` with expired cache', async () => { + createCache(-10, { + 'cache:spy:["@anne"]': 'ONNA-expired-name', + }); + + const spy = vi.fn(getUsernameDemo); + const updaterItem = new CachedFunction('spy', {updater: spy}); + + assert.equal(await updaterItem.get('@anne'), 'ANNE'); + assert.equal(chrome.storage.local.get.lastCall.args[0], 'cache:spy:["@anne"]'); + expect(spy).toHaveBeenNthCalledWith(1, '@anne'); + assert.equal(chrome.storage.local.set.lastCall.args[0]['cache:spy:["@anne"]'].data, 'ANNE'); +}); + +test('`updater` with empty cache and staleWhileRevalidate', async () => { + const maxAge = 1; + const staleWhileRevalidate = 29; + + const spy = vi.fn(getUsernameDemo); + const updaterItem = new CachedFunction('spy', { + updater: spy, + maxAge: {days: maxAge}, + staleWhileRevalidate: {days: staleWhileRevalidate}, + }); + + assert.equal(await updaterItem.get('@anne'), 'ANNE'); + + assert.equal(chrome.storage.local.get.lastCall.args[0], 'cache:spy:["@anne"]'); + assert.equal(chrome.storage.local.set.callCount, 1); + const arguments_ = chrome.storage.local.set.lastCall.args[0]; + assert.deepEqual(Object.keys(arguments_), ['cache:spy:["@anne"]']); + assert.equal(arguments_['cache:spy:["@anne"]'].data, 'ANNE'); + + const expectedExpiration = maxAge + staleWhileRevalidate; + assert.ok(arguments_['cache:spy:["@anne"]'].maxAge > timeInTheFuture({days: expectedExpiration - 0.5})); + assert.ok(arguments_['cache:spy:["@anne"]'].maxAge < timeInTheFuture({days: expectedExpiration + 0.5})); +}); + +test('`updater` with fresh cache and staleWhileRevalidate', async () => { + createCache(30, { + 'cache:spy:["@anne"]': 'ANNE', + }); + + const spy = vi.fn(getUsernameDemo); + const updaterItem = new CachedFunction('spy', { + updater: spy, + maxAge: {days: 1}, + staleWhileRevalidate: {days: 29}, + }); + + assert.equal(await updaterItem.get('@anne'), 'ANNE'); + + // Cache is still fresh, it should be used + expect(spy).not.toHaveBeenCalled(); + assert.equal(chrome.storage.local.set.callCount, 0); + + await new Promise(resolve => { + setTimeout(resolve, 100); + }); + + // Cache is still fresh, it should never be revalidated + expect(spy).not.toHaveBeenCalled(); +}); + +test('`updater` with stale cache and staleWhileRevalidate', async () => { + createCache(15, { + 'cache:spy:["@anne"]': 'ANNE', + }); + + const spy = vi.fn(getUsernameDemo); + const updaterItem = new CachedFunction('spy', { + updater: spy, + maxAge: {days: 1}, + staleWhileRevalidate: {days: 29}, + }); + + assert.equal(await updaterItem.get('@anne'), 'ANNE'); + + assert.equal(chrome.storage.local.get.lastCall.args[0], 'cache:spy:["@anne"]'); + assert.equal(chrome.storage.local.set.callCount, 0); + + // It shouldn’t be called yet + expect(spy).not.toHaveBeenCalled(); + + await new Promise(resolve => { + setTimeout(resolve, 100); + }); + + // It should be revalidated + expect(spy).toHaveBeenCalledOnce(); + assert.equal(chrome.storage.local.set.callCount, 1); + assert.equal(chrome.storage.local.set.lastCall.args[0]['cache:spy:["@anne"]'].data, 'ANNE'); +}); + +test('`updater` varies cache by function argument', async () => { + createCache(10, { + 'cache:spy:["@anne"]': 'ANNE', + }); + + const spy = vi.fn(getUsernameDemo); + const updaterItem = new CachedFunction('spy', {updater: spy}); + + assert.equal(await updaterItem.get('@anne'), 'ANNE'); + expect(spy).not.toHaveBeenCalled(); + + assert.equal(await updaterItem.get('@mari'), 'MARI'); + expect(spy).toHaveBeenCalledOnce(); +}); + +test('`updater` accepts custom cache key generator', async () => { + createCache(10, { + 'cache:spy:["@anne",1]': 'ANNE,1', + }); + + const spy = vi.fn(getUsernameDemo); + const updaterItem = new CachedFunction('spy', {updater: spy}); + + await updaterItem.get('@anne', 1); + expect(spy).not.toHaveBeenCalled(); + + await updaterItem.get('@anne', 2); + expect(spy).toHaveBeenCalledOnce(); + + assert.equal(chrome.storage.local.get.firstCall.args[0], 'cache:spy:["@anne",1]'); + assert.equal(chrome.storage.local.get.lastCall.args[0], 'cache:spy:["@anne",2]'); +}); + +test('`updater` accepts custom string-based cache key', async () => { + createCache(10, { + 'cache:CUSTOM:["@anne",1]': 'ANNE,1', + }); + + const spy = vi.fn(getUsernameDemo); + const updaterItem = new CachedFunction('CUSTOM', {updater: spy}); + + await updaterItem.get('@anne', 1); + expect(spy).not.toHaveBeenCalled(); + + await updaterItem.get('@anne', 2); + expect(spy).toHaveBeenCalledOnce(); + + assert.equal(chrome.storage.local.get.firstCall.args[0], 'cache:CUSTOM:["@anne",1]'); + assert.equal(chrome.storage.local.get.lastCall.args[0], 'cache:CUSTOM:["@anne",2]'); +}); + +test('`updater` accepts custom string-based with non-primitive parameters', async () => { + createCache(10, { + 'cache:CUSTOM:["@anne",{"user":[1]}]': 'ANNE,1', + }); + + const spy = vi.fn(getUsernameDemo); + const updaterItem = new CachedFunction('CUSTOM', {updater: spy}); + + await updaterItem.get('@anne', {user: [1]}); + expect(spy).not.toHaveBeenCalled(); + + await updaterItem.get('@anne', {user: [2]}); + expect(spy).toHaveBeenCalledOnce(); + + assert.equal(chrome.storage.local.get.firstCall.args[0], 'cache:CUSTOM:["@anne",{"user":[1]}]'); + assert.equal(chrome.storage.local.get.lastCall.args[0], 'cache:CUSTOM:["@anne",{"user":[2]}]'); +}); + +test('`updater` verifies cache with shouldRevalidate callback', async () => { + createCache(10, { + 'cache:@anne': 'anne@', + }); + + const spy = vi.fn(getUsernameDemo); + const updaterItem = new CachedFunction('spy', { + updater: spy, + shouldRevalidate: value => value.endsWith('@'), + }); + + assert.equal(await updaterItem.get('@anne'), 'ANNE'); + assert.equal(chrome.storage.local.get.lastCall.args[0], 'cache:spy:["@anne"]'); + assert.equal(chrome.storage.local.set.lastCall.args[0]['cache:spy:["@anne"]'].data, 'ANNE'); + expect(spy).toHaveBeenCalledOnce(); +}); + +test('`updater` avoids concurrent function calls', async () => { + const spy = vi.fn(getUsernameDemo); + const updaterItem = new CachedFunction('spy', {updater: spy}); + + expect(spy).not.toHaveBeenCalled(); + + // Parallel calls + updaterItem.get('@anne'); + updaterItem.get('@anne'); + await updaterItem.get('@anne'); + expect(spy).toHaveBeenCalledOnce(); + + // Parallel calls + updaterItem.get('@new'); + updaterItem.get('@other'); + await updaterItem.get('@idk'); + expect(spy).toHaveBeenCalledTimes(4); +}); + +test('`updater` avoids concurrent function calls with complex arguments via cacheKey', async () => { + const spy = vi.fn(async (transform, user) => transform(user.name)); + + const updaterItem = new CachedFunction('spy', { + updater: spy, + cacheKey: ([fn, user]) => JSON.stringify([fn.name, user]), + }); + + expect(spy).not.toHaveBeenCalled(); + const cacheMePlease = name => name.slice(1).toUpperCase(); + + // Parallel calls + updaterItem.get(cacheMePlease, {name: '@anne'}); + updaterItem.get(cacheMePlease, {name: '@anne'}); + + await updaterItem.get(cacheMePlease, {name: '@anne'}); + expect(spy).toHaveBeenCalledOnce(); + + // Parallel calls + updaterItem.get(cacheMePlease, {name: '@new'}); + updaterItem.get(cacheMePlease, {name: '@other'}); + + await updaterItem.get(cacheMePlease, {name: '@idk'}); + expect(spy).toHaveBeenCalledTimes(4); +}); + +test('`updater` uses cacheKey at every call, regardless of arguments', async () => { + const cacheKey = vi.fn(arguments_ => arguments_.length); + + const updaterItem = new CachedFunction('spy', { + updater() {}, + cacheKey, + }); + + await updaterItem.get(); + await updaterItem.get(); + expect(cacheKey).toHaveBeenCalledTimes(2); + + await updaterItem.get('@anne'); + await updaterItem.get('@anne'); + expect(cacheKey).toHaveBeenCalledTimes(4); +}); + +test('`updater` always loads the data from storage, not memory', async () => { + createCache(10, { + 'cache:spy:["@anne"]': 'ANNE', + }); + + const spy = vi.fn(getUsernameDemo); + const updaterItem = new CachedFunction('spy', {updater: spy}); + + assert.equal(await updaterItem.get('@anne'), 'ANNE'); + + assert.equal(chrome.storage.local.get.callCount, 1); + assert.equal(chrome.storage.local.get.lastCall.args[0], 'cache:spy:["@anne"]'); + + createCache(10, { + 'cache:spy:["@anne"]': 'NEW ANNE', + }); + + assert.equal(await updaterItem.get('@anne'), 'NEW ANNE'); + + assert.equal(chrome.storage.local.get.callCount, 2); + assert.equal(chrome.storage.local.get.lastCall.args[0], 'cache:spy:["@anne"]'); +}); + +test('.getFresh() ignores cached value', async () => { + createCache(10, { + 'cache:spy:["@anne"]': 'OVERWRITE_ME', + }); + + const spy = vi.fn(getUsernameDemo); + const updaterItem = new CachedFunction('spy', {updater: spy}); + assert.equal(await updaterItem.getFresh('@anne'), 'ANNE'); + + expect(spy).toHaveBeenNthCalledWith(1, '@anne'); + assert.equal(chrome.storage.local.get.callCount, 0); + assert.equal(chrome.storage.local.set.lastCall.args[0]['cache:spy:["@anne"]'].data, 'ANNE'); +}); + +// TODO: Test .applyOverride diff --git a/source/cached-function.ts b/source/cached-function.ts new file mode 100644 index 0000000..5c57119 --- /dev/null +++ b/source/cached-function.ts @@ -0,0 +1,132 @@ +import {type AsyncReturnType} from 'type-fest'; +import toMilliseconds, {type TimeDescriptor} from '@sindresorhus/to-milliseconds'; +import {type CacheValue} from './cached-value.js'; +import cache, {type CacheKey, _get, timeInTheFuture} from './legacy.js'; + +function getUserKey( + name: string, + cacheKey: CacheKey | undefined, + args: Arguments, +): string { + if (!cacheKey) { + if (args.length === 0) { + return name; + } + + cacheKey = JSON.stringify; + } + + return `${name}:${cacheKey(args)}`; +} + +export default class CachedFunction< + // TODO: Review this type. While `undefined/null` can't be stored, the `updater` can return it to clear the cache + Updater extends ((...args: any[]) => Promise), + ScopedValue extends AsyncReturnType, + Arguments extends Parameters, +> { + readonly maxAge: TimeDescriptor; + readonly staleWhileRevalidate: TimeDescriptor; + + // The only reason this is not a constructor method is TypeScript: `get` must be `typeof Updater` + get = (async (...args: Arguments) => { + const getSet = async ( + userKey: string, + args: Arguments, + ): Promise => { + const freshValue = await this.#updater(...args); + if (freshValue === undefined) { + await cache.delete(userKey); + return; + } + + const milliseconds = toMilliseconds(this.maxAge) + toMilliseconds(this.staleWhileRevalidate); + + return cache.set(userKey, freshValue, {milliseconds}) as Promise; + }; + + const memoizeStorage = async (userKey: string, ...args: Arguments) => { + const cachedItem = await _get(userKey, false); + if (cachedItem === undefined || this.#shouldRevalidate?.(cachedItem.data)) { + return getSet(userKey, args); + } + + // When the expiration is earlier than the number of days specified by `staleWhileRevalidate`, it means `maxAge` has already passed and therefore the cache is stale. + if (timeInTheFuture(this.staleWhileRevalidate) > cachedItem.maxAge) { + setTimeout(getSet, 0, userKey, args); + } + + return cachedItem.data; + }; + + const userKey = getUserKey(this.name, this.#cacheKey, args); + const cached = this.#inFlightCache.get(userKey); + if (cached) { + // Avoid calling the same function twice while pending + return cached as Promise; + } + + const promise = memoizeStorage(userKey, ...args); + this.#inFlightCache.set(userKey, promise); + const del = () => { + this.#inFlightCache.delete(userKey); + }; + + promise.then(del, del); + return promise as Promise; + }) as unknown as Updater; + + #updater: Updater; + #cacheKey: CacheKey | undefined; + #shouldRevalidate: ((cachedValue: ScopedValue) => boolean) | undefined; + #inFlightCache = new Map>(); + + constructor( + public name: string, + readonly options: { + updater: Updater; + maxAge?: TimeDescriptor; + staleWhileRevalidate?: TimeDescriptor; + cacheKey?: CacheKey; + shouldRevalidate?: (cachedValue: ScopedValue) => boolean; + }, + ) { + this.#cacheKey = options.cacheKey; + this.#updater = options.updater; + this.#shouldRevalidate = options.shouldRevalidate; + this.maxAge = options.maxAge ?? {days: 30}; + this.staleWhileRevalidate = options.staleWhileRevalidate ?? {days: 0}; + } + + async getCached(...args: Arguments): Promise { + const userKey = getUserKey(this.name, this.#cacheKey, args); + return cache.get(userKey) as Promise; + } + + async applyOverride(args: Arguments, value: ScopedValue) { + if (arguments.length === 0) { + throw new TypeError('Expected a value to be stored'); + } + + const userKey = getUserKey(this.name, this.#cacheKey, args); + return cache.set(userKey, value, this.maxAge); + } + + async getFresh(...args: Arguments): Promise { + if (this.#updater === undefined) { + throw new TypeError('Cannot get fresh value without updater'); + } + + const userKey = getUserKey(this.name, this.#cacheKey, args); + return cache.set(userKey, await this.#updater(...args)) as Promise; + } + + async delete(...args: Arguments) { + const userKey = getUserKey(this.name, this.#cacheKey, args); + return cache.delete(userKey); + } + + async isCached(...args: Arguments) { + return (await this.get(...args)) !== undefined; + } +} diff --git a/source/cached-value.md b/source/cached-value.md new file mode 100644 index 0000000..861513b --- /dev/null +++ b/source/cached-value.md @@ -0,0 +1,91 @@ +_Go back to the [main documentation page.](../readme.md#api)_ + +# new CachedValue(key, options) + +This class lets you manage a specific value in the cache, preserving its type if you're using TypeScript: + +```js +import {CachedValue} from 'webext-storage-cache'; + +const url = new CachedValue('cached-url'); + +// Or in TypeScript +const url = new CachedValue('cached-url'); +``` + +> **Note**: +> The name is unique but `webext-storage-cache` doesn't save you from bad usage. Avoid reusing the same key across the extension with different values, because it will cause conflicts: + +```ts +const starNames = new CachedValue('stars', {days: 1}); +const starCount = new CachedValue('stars'); // Bad: they will override each other +``` + +## key + +Type: string + +The unique name that will be used in `chrome.storage.local` as `cache:${key}` + +## options + +### maxAge + +Type: [`TimeDescriptor`](https://github.com/sindresorhus/to-milliseconds#input)
+Default: `{days: 30}` + +The amount of time after which the cache item will expire after being each `.set()` call. + +# CachedValue#get() + +Returns the cached value of key if it exists and hasn't expired, returns `undefined` otherwise. + +```js +const cache = new CachedValue('cached-url'); +const url = await cache.get(); +// It will be `undefined` if it's not found. +``` + +# CachedValue#set(value) + +Caches the value for the amount of time specified in the `CachedValue` constructor. It returns the value itself. + +```js +const cache = new CachedValue('core-info'); +const info = await getInfoObject(); +await cache.set(info); // Cached for 30 days by default +``` + +## value + +Type: `string | number | boolean` or `array | object` of those three types. + +`undefined` will remove the cached item. For this purpose it's best to use [`CachedValue#delete()`](#CachedValue-delete) instead + +# CachedValue#isCached() + +Checks whether the item is in the cache, returns a `boolean`. + +```js +const url = new CachedValue('url'); +const isCached = await url.isCached(); +// true or false +``` + +# CachedValue.delete() + +Deletes the requested item from the cache. + +```js +const url = new CachedValue('url'); + +await url.set('https://github.com'); +console.log(await url.isCached()); // true + +await url.delete(); +console.log(await url.isCached()); // false +``` + +## License + +MIT © [Federico Brigante](https://fregante.com) diff --git a/source/cached-value.test-d.ts b/source/cached-value.test-d.ts new file mode 100644 index 0000000..e1bcf4e --- /dev/null +++ b/source/cached-value.test-d.ts @@ -0,0 +1,30 @@ +import {expectType, expectNotAssignable, expectAssignable} from 'tsd'; +import CachedValue from './cached-value.js'; + +type Primitive = boolean | number | string; +type Value = Primitive | Primitive[] | Record; + +const item = new CachedValue('key'); + +expectType>(item.isCached()); +expectType>(item.delete()); + +expectAssignable>(item.get()); +expectNotAssignable>(item.get()); +expectType>(item.get()); +expectType>(item.set('some string')); + +// @ts-expect-error Type is string +await item.set(1); + +// @ts-expect-error Type is string +await item.set(true); + +// @ts-expect-error Type is string +await item.set([true, 'string']); + +// @ts-expect-error Type is string +await item.set({wow: [true, 'string']}); + +// @ts-expect-error Type is string +await item.set(1, {days: 1}); diff --git a/source/cached-value.test.js b/source/cached-value.test.js new file mode 100644 index 0000000..58045ec --- /dev/null +++ b/source/cached-value.test.js @@ -0,0 +1,92 @@ +/* eslint-disable n/file-extension-in-import -- No alternative until this file is changed to .test.ts */ +import nodeAssert from 'node:assert'; +import {test, beforeEach, assert} from 'vitest'; +import toMilliseconds from '@sindresorhus/to-milliseconds'; +import CachedValue from './cached-value.ts'; + +function timeInTheFuture(time) { + return Date.now() + toMilliseconds(time); +} + +const testItem = new CachedValue('name'); + +function createCache(daysFromToday, wholeCache) { + for (const [key, data] of Object.entries(wholeCache)) { + chrome.storage.local.get + .withArgs(key) + .yields({[key]: { + data, + maxAge: timeInTheFuture({days: daysFromToday}), + }}); + } +} + +beforeEach(() => { + chrome.flush(); + chrome.storage.local.get.yields({}); + chrome.storage.local.set.yields(undefined); + chrome.storage.local.remove.yields(undefined); +}); + +test('get() with empty cache', async () => { + assert.equal(await testItem.get(), undefined); +}); + +test('get() with cache', async () => { + createCache(10, { + 'cache:name': 'Rico', + }); + assert.equal(await testItem.get(), 'Rico'); +}); + +test('get() with expired cache', async () => { + createCache(-10, { + 'cache:name': 'Rico', + }); + assert.equal(await testItem.get(), undefined); +}); + +test('isCached() with empty cache', async () => { + assert.equal(await testItem.isCached(), false); +}); + +test('isCached() with cache', async () => { + createCache(10, { + 'cache:name': 'Rico', + }); + assert.equal(await testItem.isCached(), true); +}); + +test('isCached() with expired cache', async () => { + createCache(-10, { + 'cache:name': 'Rico', + }); + assert.equal(await testItem.isCached(), false); +}); + +test('set() without a value', async () => { + await nodeAssert.rejects(testItem.set(), { + name: 'TypeError', + message: 'Expected a value to be stored', + }); +}); + +// TODO: must check chrome#set or chrome#delete calls +test.skip('set() with undefined', async () => { + await testItem.set('Anne'); + assert.equal(await testItem.isCached(), true); + + await testItem.set(undefined); + assert.equal(await testItem.isCached(), false); +}); + +test('set() with value', async () => { + const maxAge = 20; + const customLimitItem = new CachedValue('name', {maxAge: {days: maxAge}}); + await customLimitItem.set('Anne'); + const arguments_ = chrome.storage.local.set.lastCall.args[0]; + assert.deepEqual(Object.keys(arguments_), ['cache:name']); + assert.equal(arguments_['cache:name'].data, 'Anne'); + assert.ok(arguments_['cache:name'].maxAge > timeInTheFuture({days: maxAge - 0.5})); + assert.ok(arguments_['cache:name'].maxAge < timeInTheFuture({days: maxAge + 0.5})); +}); diff --git a/source/cached-value.ts b/source/cached-value.ts new file mode 100644 index 0000000..6d6f107 --- /dev/null +++ b/source/cached-value.ts @@ -0,0 +1,38 @@ +import {type JsonValue} from 'type-fest'; +import {type TimeDescriptor} from '@sindresorhus/to-milliseconds'; +import cache from './legacy.js'; + +// eslint-disable-next-line @typescript-eslint/ban-types -- It is a JSON value +export type CacheValue = Exclude; + +export default class CachedValue { + readonly maxAge: TimeDescriptor; + constructor( + public name: string, + options: { + maxAge?: TimeDescriptor; + } = {}, + ) { + this.maxAge = options.maxAge ?? {days: 30}; + } + + async get(): Promise { + return cache.get(this.name); + } + + async set(value: ScopedValue): Promise { + if (arguments.length === 0) { + throw new TypeError('Expected a value to be stored'); + } + + return cache.set(this.name, value, this.maxAge); + } + + async delete(): Promise { + return cache.delete(this.name); + } + + async isCached() { + return (await this.get()) !== undefined; + } +} diff --git a/source/index.ts b/source/index.ts new file mode 100644 index 0000000..0a44a5b --- /dev/null +++ b/source/index.ts @@ -0,0 +1,8 @@ +import cache from './legacy.js'; + +export {default as CachedValue} from './cached-value.js'; +export {default as CachedFunction} from './cached-function.js'; + +export const globalCache = { + clear: cache.clear, +}; diff --git a/source/legacy.test-d.ts b/source/legacy.test-d.ts new file mode 100644 index 0000000..90354de --- /dev/null +++ b/source/legacy.test-d.ts @@ -0,0 +1,18 @@ +import {expectType, expectNotAssignable, expectAssignable} from 'tsd'; +import cache from './legacy.js'; + +type Primitive = boolean | number | string; +type Value = Primitive | Primitive[] | Record; + +expectType>(cache.has('key')); +expectType>(cache.delete('key')); + +expectType>(cache.get('key')); +expectType>(cache.get('key')); +expectNotAssignable>(cache.get('key')); + +expectAssignable>(cache.set('key', 1)); +expectAssignable>(cache.set('key', true)); +expectAssignable>(cache.set('key', [true, 'string'])); +expectAssignable>>(cache.set('key', {wow: [true, 'string']})); +expectAssignable>(cache.set('key', 1, {days: 1})); diff --git a/source/legacy.test.js b/source/legacy.test.js new file mode 100644 index 0000000..fb57c70 --- /dev/null +++ b/source/legacy.test.js @@ -0,0 +1,96 @@ +/* eslint-disable n/file-extension-in-import -- No alternative until this file is changed to .test.ts */ +import nodeAssert from 'node:assert'; +import {test, beforeEach, assert} from 'vitest'; +import toMilliseconds from '@sindresorhus/to-milliseconds'; +import cache from './legacy.ts'; + +// Help migration away from AVA +const t = { + is: assert.equal, + not: assert.notEqual, + true: assert.ok, + deepEqual: assert.deepEqual, + throwsAsync: nodeAssert.rejects, +}; + +function timeInTheFuture(time) { + return Date.now() + toMilliseconds(time); +} + +function createCache(daysFromToday, wholeCache) { + for (const [key, data] of Object.entries(wholeCache)) { + chrome.storage.local.get + .withArgs(key) + .yields({[key]: { + data, + maxAge: timeInTheFuture({days: daysFromToday}), + }}); + } +} + +beforeEach(() => { + chrome.flush(); + chrome.storage.local.get.yields({}); + chrome.storage.local.set.yields(undefined); + chrome.storage.local.remove.yields(undefined); +}); + +test('get() with empty cache', async () => { + t.is(await cache.get('name'), undefined); +}); + +test('get() with cache', async () => { + createCache(10, { + 'cache:name': 'Rico', + }); + t.is(await cache.get('name'), 'Rico'); +}); + +test('get() with expired cache', async () => { + createCache(-10, { + 'cache:name': 'Rico', + }); + t.is(await cache.get('name'), undefined); +}); + +test('has() with empty cache', async () => { + t.is(await cache.has('name'), false); +}); + +test('has() with cache', async () => { + createCache(10, { + 'cache:name': 'Rico', + }); + t.is(await cache.has('name'), true); +}); + +test('has() with expired cache', async () => { + createCache(-10, { + 'cache:name': 'Rico', + }); + t.is(await cache.has('name'), false); +}); + +test('set() without a value', async () => { + await t.throwsAsync(cache.set('name'), { + name: 'TypeError', + message: 'Expected a value as the second argument', + }); +}); + +test('set() with undefined', async () => { + await cache.set('name', 'Anne'); + await cache.set('name', undefined); + // Cached value should be erased + t.is(await cache.has('name'), false); +}); + +test('set() with value', async () => { + const maxAge = 20; + await cache.set('name', 'Anne', {days: maxAge}); + const arguments_ = chrome.storage.local.set.lastCall.args[0]; + t.deepEqual(Object.keys(arguments_), ['cache:name']); + t.is(arguments_['cache:name'].data, 'Anne'); + t.true(arguments_['cache:name'].maxAge > timeInTheFuture({days: maxAge - 0.5})); + t.true(arguments_['cache:name'].maxAge < timeInTheFuture({days: maxAge + 0.5})); +}); diff --git a/index.ts b/source/legacy.ts similarity index 54% rename from index.ts rename to source/legacy.ts index 0e808fc..438ad57 100644 --- a/index.ts +++ b/source/legacy.ts @@ -4,46 +4,30 @@ import toMilliseconds, {type TimeDescriptor} from '@sindresorhus/to-milliseconds const cacheDefault = {days: 30}; -function timeInTheFuture(time: TimeDescriptor): number { +export function timeInTheFuture(time: TimeDescriptor): number { return Date.now() + toMilliseconds(time); } -export function defaultSerializer(arguments_: unknown[]): string { - if (arguments_.every(arg => typeof arg === 'string')) { - return arguments_.join(','); - } - - return JSON.stringify(arguments_); -} - type Primitive = boolean | number | string; type Value = Primitive | Primitive[] | Record; // No circular references: Record https://github.com/Microsoft/TypeScript/issues/14174 // No index signature: {[key: string]: Value} https://github.com/microsoft/TypeScript/issues/15300#issuecomment-460226926 -type CacheItem = { +type CachedValue = { data: Value; maxAge: number; }; -type Cache = Record>; - -function getUserKey( - name: string, - cacheKey: CacheKey, - args: Arguments, -): string { - return `${name}:${cacheKey(args)}`; -} +type Cache = Record>; async function has(key: string): Promise { return (await _get(key, false)) !== undefined; } -async function _get( +export async function _get( key: string, remove: boolean, -): Promise | undefined> { +): Promise | undefined> { const internalKey = `cache:${key}`; const storageData = await chromeP.storage.local.get(internalKey) as Cache; const cachedItem = storageData[internalKey]; @@ -67,8 +51,8 @@ async function _get( async function get( key: string, ): Promise { - const cacheItem = await _get(key, true); - return cacheItem?.data; + const cachedValue = await _get(key, true); + return cachedValue?.data; } async function set( @@ -95,13 +79,13 @@ async function set( return value; } -async function delete_(key: string): Promise { - const internalKey = `cache:${key}`; +async function delete_(userKey: string): Promise { + const internalKey = `cache:${userKey}`; return chromeP.storage.local.remove(internalKey); } async function deleteWithLogic( - logic?: (x: CacheItem) => boolean, + logic?: (x: CachedValue) => boolean, ): Promise { const wholeCache = (await chromeP.storage.local.get()) as Record; const removableItems: string[] = []; @@ -125,90 +109,21 @@ async function clear(): Promise { await deleteWithLogic(); } -type CacheKey = (args: Arguments) => string; +export type CacheKey = (args: Arguments) => string; -type MemoizedFunctionOptions = { +export type MemoizedFunctionOptions = { maxAge?: TimeDescriptor; staleWhileRevalidate?: TimeDescriptor; cacheKey?: CacheKey; shouldRevalidate?: (cachedValue: ScopedValue) => boolean; }; -function function_< - ScopedValue extends Value, - Getter extends (...args: any[]) => Promise, - Arguments extends Parameters, ->( - name: string, - getter: Getter, - { - cacheKey = defaultSerializer, - maxAge = {days: 30}, - staleWhileRevalidate = {days: 0}, - shouldRevalidate, - }: MemoizedFunctionOptions = {}, -): Getter & {fresh: Getter} { - const inFlightCache = new Map>(); - const getSet = async ( - key: string, - args: Arguments, - ): Promise => { - const freshValue = await getter(...args); - if (freshValue === undefined) { - await delete_(key); - return; - } - - const milliseconds = toMilliseconds(maxAge) + toMilliseconds(staleWhileRevalidate); - - return set(key, freshValue, {milliseconds}); - }; - - const memoizeStorage = async (userKey: string, ...args: Arguments) => { - const cachedItem = await _get(userKey, false); - if (cachedItem === undefined || shouldRevalidate?.(cachedItem.data)) { - return getSet(userKey, args); - } - - // When the expiration is earlier than the number of days specified by `staleWhileRevalidate`, it means `maxAge` has already passed and therefore the cache is stale. - if (timeInTheFuture(staleWhileRevalidate) > cachedItem.maxAge) { - setTimeout(getSet, 0, userKey, args); - } - - return cachedItem.data; - }; - - // eslint-disable-next-line @typescript-eslint/promise-function-async -- Tests expect the same exact promise to be returned - function memoizePending(...args: Arguments) { - const userKey = getUserKey(name, cacheKey, args); - if (inFlightCache.has(userKey)) { - // Avoid calling the same function twice while pending - return inFlightCache.get(userKey); - } - - const promise = memoizeStorage(userKey, ...args); - inFlightCache.set(userKey, promise); - const del = () => { - inFlightCache.delete(userKey); - }; - - promise.then(del, del); - return promise; - } - - return Object.assign(memoizePending as Getter, { - fresh: ( - async (...args: Arguments) => getSet(getUserKey(name, cacheKey, args), args) - ) as Getter, - }); -} - +/** @deprecated Use CachedValue and CachedFunction instead */ const cache = { has, get, set, clear, - function: function_, delete: delete_, }; diff --git a/test/cache-item.js b/test/cache-item.js deleted file mode 100644 index 170f3b5..0000000 --- a/test/cache-item.js +++ /dev/null @@ -1,361 +0,0 @@ -import test from 'ava'; -import sinon from 'sinon'; -import toMilliseconds from '@sindresorhus/to-milliseconds'; -import cache from '../index.js'; - -const getUsernameDemo = async name => name.slice(1).toUpperCase(); - -function timeInTheFuture(time) { - return Date.now() + toMilliseconds(time); -} - -function createCache(daysFromToday, wholeCache) { - for (const [key, data] of Object.entries(wholeCache)) { - chrome.storage.local.get - .withArgs(key) - .yields({[key]: { - data, - maxAge: timeInTheFuture({days: daysFromToday}), - }}); - } -} - -test.beforeEach(() => { - chrome.flush(); - chrome.storage.local.get.yields({}); - chrome.storage.local.set.yields(undefined); - chrome.storage.local.remove.yields(undefined); -}); - -test.serial('get() with empty cache', async t => { - t.is(await cache.get('name'), undefined); -}); - -test.serial('get() with cache', async t => { - createCache(10, { - 'cache:name': 'Rico', - }); - t.is(await cache.get('name'), 'Rico'); -}); - -test.serial('get() with expired cache', async t => { - createCache(-10, { - 'cache:name': 'Rico', - }); - t.is(await cache.get('name'), undefined); -}); - -test.serial('has() with empty cache', async t => { - t.is(await cache.has('name'), false); -}); - -test.serial('has() with cache', async t => { - createCache(10, { - 'cache:name': 'Rico', - }); - t.is(await cache.has('name'), true); -}); - -test.serial('has() with expired cache', async t => { - createCache(-10, { - 'cache:name': 'Rico', - }); - t.is(await cache.has('name'), false); -}); - -test.serial('set() without a value', async t => { - await t.throwsAsync(cache.set('name'), { - instanceOf: TypeError, - message: 'Expected a value as the second argument', - }); -}); - -test.serial('set() with undefined', async t => { - await cache.set('name', 'Anne'); - await cache.set('name', undefined); - // Cached value should be erased - t.is(await cache.has('name'), false); -}); - -test.serial('set() with value', async t => { - const maxAge = 20; - await cache.set('name', 'Anne', {days: maxAge}); - const arguments_ = chrome.storage.local.set.lastCall.args[0]; - t.deepEqual(Object.keys(arguments_), ['cache:name']); - t.is(arguments_['cache:name'].data, 'Anne'); - t.true(arguments_['cache:name'].maxAge > timeInTheFuture({days: maxAge - 0.5})); - t.true(arguments_['cache:name'].maxAge < timeInTheFuture({days: maxAge + 0.5})); -}); - -test.serial('function() with empty cache', async t => { - const spy = sinon.spy(getUsernameDemo); - const call = cache.function('spy', spy); - - t.is(await call('@anne'), 'ANNE'); - - t.is(chrome.storage.local.get.lastCall.args[0], 'cache:spy:@anne'); - t.true(spy.withArgs('@anne').calledOnce); - t.is(spy.callCount, 1); - t.is(chrome.storage.local.set.lastCall.args[0]['cache:spy:@anne'].data, 'ANNE'); -}); - -test.serial('function() with cache', async t => { - createCache(10, { - 'cache:spy:@anne': 'ANNE', - }); - - const spy = sinon.spy(getUsernameDemo); - const call = cache.function('spy', spy); - - t.is(await call('@anne'), 'ANNE'); - - t.is(chrome.storage.local.get.lastCall.args[0], 'cache:spy:@anne'); - t.is(chrome.storage.local.set.callCount, 0); - t.is(spy.callCount, 0); -}); - -test.serial('function() with expired cache', async t => { - createCache(-10, { - 'cache:spy:@anne': 'ONNA', - }); - - const spy = sinon.spy(getUsernameDemo); - const call = cache.function('spy', spy); - - t.is(await cache.get('@anne'), undefined); - t.is(await call('@anne'), 'ANNE'); - t.is(chrome.storage.local.get.lastCall.args[0], 'cache:spy:@anne'); - t.true(spy.withArgs('@anne').calledOnce); - t.is(spy.callCount, 1); - t.is(chrome.storage.local.set.lastCall.args[0]['cache:spy:@anne'].data, 'ANNE'); -}); - -test.serial('function() with empty cache and staleWhileRevalidate', async t => { - const maxAge = 1; - const staleWhileRevalidate = 29; - - const spy = sinon.spy(getUsernameDemo); - const call = cache.function('spy', spy, { - maxAge: {days: maxAge}, - staleWhileRevalidate: {days: staleWhileRevalidate}, - }); - - t.is(await call('@anne'), 'ANNE'); - - t.is(chrome.storage.local.get.lastCall.args[0], 'cache:spy:@anne'); - t.is(chrome.storage.local.set.callCount, 1); - const arguments_ = chrome.storage.local.set.lastCall.args[0]; - t.deepEqual(Object.keys(arguments_), ['cache:spy:@anne']); - t.is(arguments_['cache:spy:@anne'].data, 'ANNE'); - - const expectedExpiration = maxAge + staleWhileRevalidate; - t.true(arguments_['cache:spy:@anne'].maxAge > timeInTheFuture({days: expectedExpiration - 0.5})); - t.true(arguments_['cache:spy:@anne'].maxAge < timeInTheFuture({days: expectedExpiration + 0.5})); -}); - -test.serial('function() with fresh cache and staleWhileRevalidate', async t => { - createCache(30, { - 'cache:spy:@anne': 'ANNE', - }); - - const spy = sinon.spy(getUsernameDemo); - const call = cache.function('spy', spy, { - maxAge: {days: 1}, - staleWhileRevalidate: {days: 29}, - }); - - t.is(await call('@anne'), 'ANNE'); - - // Cache is still fresh, it should be used - t.is(spy.callCount, 0); - t.is(chrome.storage.local.set.callCount, 0); - - await new Promise(resolve => { - setTimeout(resolve, 100); - }); - - // Cache is still fresh, it should never be revalidated - t.is(spy.callCount, 0); -}); - -test.serial('function() with stale cache and staleWhileRevalidate', async t => { - createCache(15, { - 'cache:spy:@anne': 'ANNE', - }); - - const spy = sinon.spy(getUsernameDemo); - const call = cache.function('spy', spy, { - maxAge: {days: 1}, - staleWhileRevalidate: {days: 29}, - }); - - t.is(await call('@anne'), 'ANNE'); - - t.is(chrome.storage.local.get.lastCall.args[0], 'cache:spy:@anne'); - t.is(chrome.storage.local.set.callCount, 0); - - t.is(spy.callCount, 0, 'It shouldn’t be called yet'); - - await new Promise(resolve => { - setTimeout(resolve, 100); - }); - - t.is(spy.callCount, 1, 'It should be revalidated'); - t.is(chrome.storage.local.set.callCount, 1); - t.is(chrome.storage.local.set.lastCall.args[0]['cache:spy:@anne'].data, 'ANNE'); -}); - -test.serial('function() varies cache by function argument', async t => { - createCache(10, { - 'cache:spy:@anne': 'ANNE', - }); - - const spy = sinon.spy(getUsernameDemo); - const call = cache.function('spy', spy); - - t.is(await call('@anne'), 'ANNE'); - t.is(spy.callCount, 0); - - t.is(await call('@mari'), 'MARI'); - t.is(spy.callCount, 1); -}); - -test.serial('function() accepts custom cache key generator', async t => { - createCache(10, { - 'cache:spy:@anne,1': 'ANNE,1', - }); - - const spy = sinon.spy(getUsernameDemo); - const call = cache.function('spy', spy); - - await call('@anne', '1'); - t.is(spy.callCount, 0); - - await call('@anne', '2'); - t.is(spy.callCount, 1); - - t.is(chrome.storage.local.get.firstCall.args[0], 'cache:spy:@anne,1'); - t.is(chrome.storage.local.get.lastCall.args[0], 'cache:spy:@anne,2'); -}); - -test.serial('function() accepts custom string-based cache key', async t => { - createCache(10, { - 'cache:CUSTOM:["@anne",1]': 'ANNE,1', - }); - - const spy = sinon.spy(getUsernameDemo); - const call = cache.function('CUSTOM', spy); - - await call('@anne', 1); - t.is(spy.callCount, 0); - - await call('@anne', 2); - t.is(spy.callCount, 1); - - t.is(chrome.storage.local.get.firstCall.args[0], 'cache:CUSTOM:["@anne",1]'); - t.is(chrome.storage.local.get.lastCall.args[0], 'cache:CUSTOM:["@anne",2]'); -}); - -test.serial('function() accepts custom string-based with non-primitive parameters', async t => { - createCache(10, { - 'cache:CUSTOM:["@anne",{"user":[1]}]': 'ANNE,1', - }); - - const spy = sinon.spy(getUsernameDemo); - const call = cache.function('CUSTOM', spy); - - await call('@anne', {user: [1]}); - t.is(spy.callCount, 0); - - await call('@anne', {user: [2]}); - t.is(spy.callCount, 1); - - t.is(chrome.storage.local.get.firstCall.args[0], 'cache:CUSTOM:["@anne",{"user":[1]}]'); - t.is(chrome.storage.local.get.lastCall.args[0], 'cache:CUSTOM:["@anne",{"user":[2]}]'); -}); - -test.serial('function() verifies cache with shouldRevalidate callback', async t => { - createCache(10, { - 'cache:@anne': 'anne@', - }); - - const spy = sinon.spy(getUsernameDemo); - const call = cache.function('spy', spy, { - shouldRevalidate: value => value.endsWith('@'), - }); - - t.is(await call('@anne'), 'ANNE'); - t.is(chrome.storage.local.get.lastCall.args[0], 'cache:spy:@anne'); - t.is(chrome.storage.local.set.lastCall.args[0]['cache:spy:@anne'].data, 'ANNE'); - t.is(spy.callCount, 1); -}); - -test.serial('function() avoids concurrent function calls', async t => { - const spy = sinon.spy(getUsernameDemo); - const call = cache.function('spy', spy); - - t.is(spy.callCount, 0); - t.is(call('@anne'), call('@anne')); - await call('@anne'); - t.is(spy.callCount, 1); - - t.not(call('@new'), call('@other')); - await call('@idk'); - t.is(spy.callCount, 4); -}); - -test.serial('function() avoids concurrent function calls with complex arguments via cacheKey', async t => { - const spy = sinon.spy(async (transform, user) => transform(user.name)); - const call = cache.function('spy', spy, { - cacheKey: ([fn, user]) => JSON.stringify([fn.name, user]), - }); - - t.is(spy.callCount, 0); - const cacheMePlease = name => name.slice(1).toUpperCase(); - t.is(call(cacheMePlease, {name: '@anne'}), call(cacheMePlease, {name: '@anne'})); - await call(cacheMePlease, {name: '@anne'}); - t.is(spy.callCount, 1); - - t.not(call(cacheMePlease, {name: '@new'}), call(cacheMePlease, {name: '@other'})); - await call(cacheMePlease, {name: '@idk'}); - t.is(spy.callCount, 4); -}); - -test.serial('function() always loads the data from storage, not memory', async t => { - createCache(10, { - 'cache:spy:@anne': 'ANNE', - }); - - const spy = sinon.spy(getUsernameDemo); - const call = cache.function('spy', spy); - - t.is(await call('@anne'), 'ANNE'); - - t.is(chrome.storage.local.get.callCount, 1); - t.is(chrome.storage.local.get.lastCall.args[0], 'cache:spy:@anne'); - - createCache(10, { - 'cache:spy:@anne': 'NEW ANNE', - }); - - t.is(await call('@anne'), 'NEW ANNE'); - - t.is(chrome.storage.local.get.callCount, 2); - t.is(chrome.storage.local.get.lastCall.args[0], 'cache:spy:@anne'); -}); - -test.serial('function.fresh() ignores cached value', async t => { - createCache(10, { - 'cache:spy:@anne': 'OVERWRITE_ME', - }); - - const spy = sinon.spy(getUsernameDemo); - const call = cache.function('spy', spy); - - t.is(await call.fresh('@anne'), 'ANNE'); - - t.true(spy.withArgs('@anne').calledOnce); - t.is(spy.callCount, 1); - t.is(chrome.storage.local.get.callCount, 0); - t.is(chrome.storage.local.set.lastCall.args[0]['cache:spy:@anne'].data, 'ANNE'); -}); diff --git a/tsconfig.json b/tsconfig.json index 6743664..6ac48fb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,12 +1,10 @@ { "extends": "@sindresorhus/tsconfig", "compilerOptions": { - "outDir": ".", - "target": "ES2022", - "checkJs": true + "outDir": "distribution", + "target": "ES2022" }, - "files": [ - "index.test-d.ts", - "index.ts" + "include": [ + "source", ] } diff --git a/vite.config.ts b/vite.config.ts index 489c6fe..fcfe504 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,7 +4,7 @@ import {defineConfig} from 'vitest/config'; export default defineConfig({ test: { setupFiles: [ - './test/_setup.js', + './vitest.setup.js', ], }, }); diff --git a/test/_setup.js b/vitest.setup.js similarity index 100% rename from test/_setup.js rename to vitest.setup.js