From 26a407c123592e1c173f2d2bb90780b4b45c8b5f Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Thu, 25 May 2023 16:52:44 +0800 Subject: [PATCH 01/27] First pass --- .gitignore | 1 + cache-item.ts | 113 ++++++++++++++++++++++++++++++++++++++++++++++++++ index.ts | 14 +++---- package.json | 1 + tsconfig.json | 1 + 5 files changed, 123 insertions(+), 7 deletions(-) create mode 100644 cache-item.ts diff --git a/.gitignore b/.gitignore index 44c0175..baa8bea 100644 --- a/.gitignore +++ b/.gitignore @@ -9,5 +9,6 @@ Thumbs.db logs *.map /index.js +/cache-item.js /index.test-d.js *.d.ts diff --git a/cache-item.ts b/cache-item.ts new file mode 100644 index 0000000..2c345cb --- /dev/null +++ b/cache-item.ts @@ -0,0 +1,113 @@ +import {type JsonValue} from 'type-fest'; +import toMilliseconds, {type TimeDescriptor} from '@sindresorhus/to-milliseconds'; +import cache, {getUserKey, type CacheKey, defaultSerializer, _get, timeInTheFuture} from './index.js'; + +// eslint-disable-next-line @typescript-eslint/ban-types -- It is a JSON value +export type CacheValue = Exclude; + +export type CacheOptions < + ScopedValue, + Updater extends ((...args: unknown[]) => Promise) = ((...args: unknown[]) => Promise), + Arguments extends unknown[] = Parameters, +> = { + maxAge?: TimeDescriptor; + staleWhileRevalidate?: TimeDescriptor; + cacheKey?: CacheKey; + shouldRevalidate?: (cachedValue: ScopedValue) => boolean; + updater?: Updater; +}; + +export default class CacheItem< + ScopedValue extends CacheValue, + Updater extends ((...args: unknown[]) => Promise) = ((...args: unknown[]) => Promise), + Arguments extends unknown[] = Parameters, +> { + #cacheKey: CacheKey; + #updater: Updater | undefined; + #shouldRevalidate: ((cachedValue: ScopedValue) => boolean) | undefined; + readonly maxAge: TimeDescriptor; + readonly staleWhileRevalidate: TimeDescriptor; + + constructor( + public name: string, + readonly options: CacheOptions = {}, + ) { + this.#cacheKey = options.cacheKey ?? defaultSerializer; + this.#updater = options.updater as Updater | undefined; + this.#shouldRevalidate = options.shouldRevalidate; + this.maxAge = options.maxAge ?? {days: 30}; + this.staleWhileRevalidate = options.staleWhileRevalidate ?? {days: 0}; + console.log('done'); + } + + async getCached(...args: Arguments) { + const userKey = getUserKey(this.name, this.#cacheKey, args); + return cache.get(userKey); + } + + async set(value: ScopedValue, ...args: Arguments) { + const userKey = getUserKey(this.name, this.#cacheKey, args); + return cache.set(userKey, value, this.maxAge); + } + + async getFresh(...args: Arguments) { + 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)); + } + + async delete(...args: Arguments) { + const userKey = getUserKey(this.name, this.#cacheKey, args); + return cache.delete(userKey); + } + + async get(...args: Arguments) { + const inFlightCache = new Map>(); + 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}); + }; + + 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); + 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; + } +} diff --git a/index.ts b/index.ts index bc0eb09..2bd2578 100644 --- a/index.ts +++ b/index.ts @@ -4,7 +4,7 @@ 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); } @@ -28,7 +28,7 @@ type CacheItem = { type Cache = Record>; -function getUserKey( +export function getUserKey( name: string, cacheKey: CacheKey, args: Arguments, @@ -40,7 +40,7 @@ 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> { @@ -95,8 +95,8 @@ 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); } @@ -124,9 +124,9 @@ 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; diff --git a/package.json b/package.json index 88cfa1e..0d09aef 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ }, "dependencies": { "@sindresorhus/to-milliseconds": "^2.0.0", + "type-fest": "^3.11.0", "webext-detect-page": "^4.0.1", "webext-polyfill-kinda": "^1.0.0" }, diff --git a/tsconfig.json b/tsconfig.json index 46ccd72..c120713 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "checkJs": true }, "files": [ + "cache-item.ts", "index.test-d.ts", "index.ts" ] From 528950bd57b40b3b0ea2ab1ea569c2f3bb35c4ff Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Thu, 25 May 2023 21:55:20 +0800 Subject: [PATCH 02/27] Add tests for cache-item --- cache-item.test.js | 381 +++++++++++++++++++++++++++++++++++++++++++++ cache-item.ts | 24 ++- index.ts | 4 + test/cache-item.js | 361 ------------------------------------------ 4 files changed, 403 insertions(+), 367 deletions(-) create mode 100644 cache-item.test.js delete mode 100644 test/cache-item.js diff --git a/cache-item.test.js b/cache-item.test.js new file mode 100644 index 0000000..131ac48 --- /dev/null +++ b/cache-item.test.js @@ -0,0 +1,381 @@ +import nodeAssert from 'node:assert'; +import {test, beforeEach, vi, assert, expect} from 'vitest'; +import toMilliseconds from '@sindresorhus/to-milliseconds'; +import CacheItem from './cache-item.js'; + +const getUsernameDemo = async name => name.slice(1).toUpperCase(); + +function timeInTheFuture(time) { + return Date.now() + toMilliseconds(time); +} + +const testItem = new CacheItem('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 CacheItem('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})); +}); + +test('function() with empty cache', async () => { + const spy = vi.fn(getUsernameDemo); + const updaterItem = new CacheItem('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('function() with cache', async () => { + createCache(10, { + 'cache:spy:@anne': 'ANNE', + }); + + const spy = vi.fn(getUsernameDemo); + const updaterItem = new CacheItem('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('function() with expired cache', async () => { + createCache(-10, { + 'cache:spy:@anne': 'ONNA-expired-name', + }); + + const spy = vi.fn(getUsernameDemo); + const updaterItem = new CacheItem('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('function() with empty cache and staleWhileRevalidate', async () => { + const maxAge = 1; + const staleWhileRevalidate = 29; + + const spy = vi.fn(getUsernameDemo); + const updaterItem = new CacheItem('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('function() with fresh cache and staleWhileRevalidate', async () => { + createCache(30, { + 'cache:spy:@anne': 'ANNE', + }); + + const spy = vi.fn(getUsernameDemo); + const updaterItem = new CacheItem('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('function() with stale cache and staleWhileRevalidate', async () => { + createCache(15, { + 'cache:spy:@anne': 'ANNE', + }); + + const spy = vi.fn(getUsernameDemo); + const updaterItem = new CacheItem('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('function() varies cache by function argument', async () => { + createCache(10, { + 'cache:spy:@anne': 'ANNE', + }); + + const spy = vi.fn(getUsernameDemo); + const updaterItem = new CacheItem('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('function() accepts custom cache key generator', async () => { + createCache(10, { + 'cache:spy:@anne,1': 'ANNE,1', + }); + + const spy = vi.fn(getUsernameDemo); + const updaterItem = new CacheItem('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('function() accepts custom string-based cache key', async () => { + createCache(10, { + 'cache:CUSTOM:["@anne",1]': 'ANNE,1', + }); + + const spy = vi.fn(getUsernameDemo); + const updaterItem = new CacheItem('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('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 updaterItem = new CacheItem('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('function() verifies cache with shouldRevalidate callback', async () => { + createCache(10, { + 'cache:@anne': 'anne@', + }); + + const spy = vi.fn(getUsernameDemo); + const updaterItem = new CacheItem('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('function() avoids concurrent function calls', async () => { + const spy = vi.fn(getUsernameDemo); + const updaterItem = new CacheItem('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('function() avoids concurrent function calls with complex arguments via cacheKey', async () => { + const spy = vi.fn(async (transform, user) => transform(user.name)); + + const updaterItem = new CacheItem('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('function() always loads the data from storage, not memory', async () => { + createCache(10, { + 'cache:spy:@anne': 'ANNE', + }); + + const spy = vi.fn(getUsernameDemo); + const updaterItem = new CacheItem('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 CacheItem('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'); +}); diff --git a/cache-item.ts b/cache-item.ts index 55d89c6..0f719ac 100644 --- a/cache-item.ts +++ b/cache-item.ts @@ -24,9 +24,11 @@ export default class CacheItem< > { readonly maxAge: TimeDescriptor; readonly staleWhileRevalidate: TimeDescriptor; + #cacheKey: CacheKey; #updater: Updater | undefined; #shouldRevalidate: ((cachedValue: ScopedValue) => boolean) | undefined; + #inFlightCache = new Map>(); constructor( public name: string, @@ -37,7 +39,6 @@ export default class CacheItem< this.#shouldRevalidate = options.shouldRevalidate; this.maxAge = options.maxAge ?? {days: 30}; this.staleWhileRevalidate = options.staleWhileRevalidate ?? {days: 0}; - console.log('done'); } async getCached(...args: Arguments) { @@ -46,6 +47,10 @@ export default class CacheItem< } async set(value: ScopedValue, ...args: Arguments) { + 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); } @@ -64,8 +69,15 @@ export default class CacheItem< return cache.delete(userKey); } + async isCached(...args: Arguments) { + return (await this.get(...args)) !== undefined; + } + async get(...args: Arguments) { - const inFlightCache = new Map>(); + if (!this.#updater) { + return this.getCached(...args); + } + const getSet = async ( userKey: string, args: Arguments, @@ -96,15 +108,15 @@ export default class CacheItem< }; const userKey = getUserKey(this.name, this.#cacheKey, args); - if (inFlightCache.has(userKey)) { + if (this.#inFlightCache.has(userKey)) { // Avoid calling the same function twice while pending - return inFlightCache.get(userKey); + return this.#inFlightCache.get(userKey); } const promise = memoizeStorage(userKey, ...args); - inFlightCache.set(userKey, promise); + this.#inFlightCache.set(userKey, promise); const del = () => { - inFlightCache.delete(userKey); + this.#inFlightCache.delete(userKey); }; promise.then(del, del); diff --git a/index.ts b/index.ts index 80e569a..fae1afe 100644 --- a/index.ts +++ b/index.ts @@ -33,6 +33,10 @@ export function getUserKey( cacheKey: CacheKey, args: Arguments, ): string { + if (args.length === 0) { + return name; + } + return `${name}:${cacheKey(args)}`; } 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'); -}); From cfd43b30809c8d86b7b1eef4b98b7c9ff1ba0048 Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Fri, 26 May 2023 01:00:41 +0800 Subject: [PATCH 03/27] Test `getCached` --- cache-item.test.js | 55 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/cache-item.test.js b/cache-item.test.js index 131ac48..1194804 100644 --- a/cache-item.test.js +++ b/cache-item.test.js @@ -47,6 +47,33 @@ test('get() with expired cache', async () => { assert.equal(await testItem.get(), undefined); }); +test('getCached() with empty cache', async () => { + const spy = vi.fn(getUsernameDemo); + const testItem = new CacheItem('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 CacheItem('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 CacheItem('name', {updater: spy}); + createCache(-10, { + 'cache:name': 'Rico', + }); + assert.equal(await testItem.getCached(), undefined); + expect(spy).not.toHaveBeenCalled(); +}); + test('isCached() with empty cache', async () => { assert.equal(await testItem.isCached(), false); }); @@ -92,7 +119,7 @@ test('set() with value', async () => { assert.ok(arguments_['cache:name'].maxAge < timeInTheFuture({days: maxAge + 0.5})); }); -test('function() with empty cache', async () => { +test('`updater` with empty cache', async () => { const spy = vi.fn(getUsernameDemo); const updaterItem = new CacheItem('spy', {updater: spy}); @@ -103,7 +130,7 @@ test('function() with empty cache', async () => { assert.equal(chrome.storage.local.set.lastCall.args[0]['cache:spy:@anne'].data, 'ANNE'); }); -test('function() with cache', async () => { +test('`updater` with cache', async () => { createCache(10, { 'cache:spy:@anne': 'ANNE', }); @@ -118,7 +145,7 @@ test('function() with cache', async () => { expect(spy).not.toHaveBeenCalled(); }); -test('function() with expired cache', async () => { +test('`updater` with expired cache', async () => { createCache(-10, { 'cache:spy:@anne': 'ONNA-expired-name', }); @@ -132,7 +159,7 @@ test('function() with expired cache', async () => { assert.equal(chrome.storage.local.set.lastCall.args[0]['cache:spy:@anne'].data, 'ANNE'); }); -test('function() with empty cache and staleWhileRevalidate', async () => { +test('`updater` with empty cache and staleWhileRevalidate', async () => { const maxAge = 1; const staleWhileRevalidate = 29; @@ -156,7 +183,7 @@ test('function() with empty cache and staleWhileRevalidate', async () => { assert.ok(arguments_['cache:spy:@anne'].maxAge < timeInTheFuture({days: expectedExpiration + 0.5})); }); -test('function() with fresh cache and staleWhileRevalidate', async () => { +test('`updater` with fresh cache and staleWhileRevalidate', async () => { createCache(30, { 'cache:spy:@anne': 'ANNE', }); @@ -182,7 +209,7 @@ test('function() with fresh cache and staleWhileRevalidate', async () => { expect(spy).not.toHaveBeenCalled(); }); -test('function() with stale cache and staleWhileRevalidate', async () => { +test('`updater` with stale cache and staleWhileRevalidate', async () => { createCache(15, { 'cache:spy:@anne': 'ANNE', }); @@ -212,7 +239,7 @@ test('function() with stale cache and staleWhileRevalidate', async () => { assert.equal(chrome.storage.local.set.lastCall.args[0]['cache:spy:@anne'].data, 'ANNE'); }); -test('function() varies cache by function argument', async () => { +test('`updater` varies cache by function argument', async () => { createCache(10, { 'cache:spy:@anne': 'ANNE', }); @@ -227,7 +254,7 @@ test('function() varies cache by function argument', async () => { expect(spy).toHaveBeenCalledOnce(); }); -test('function() accepts custom cache key generator', async () => { +test('`updater` accepts custom cache key generator', async () => { createCache(10, { 'cache:spy:@anne,1': 'ANNE,1', }); @@ -245,7 +272,7 @@ test('function() accepts custom cache key generator', async () => { assert.equal(chrome.storage.local.get.lastCall.args[0], 'cache:spy:@anne,2'); }); -test('function() accepts custom string-based cache key', async () => { +test('`updater` accepts custom string-based cache key', async () => { createCache(10, { 'cache:CUSTOM:["@anne",1]': 'ANNE,1', }); @@ -263,7 +290,7 @@ test('function() accepts custom string-based cache key', async () => { assert.equal(chrome.storage.local.get.lastCall.args[0], 'cache:CUSTOM:["@anne",2]'); }); -test('function() accepts custom string-based with non-primitive parameters', async () => { +test('`updater` accepts custom string-based with non-primitive parameters', async () => { createCache(10, { 'cache:CUSTOM:["@anne",{"user":[1]}]': 'ANNE,1', }); @@ -281,7 +308,7 @@ test('function() accepts custom string-based with non-primitive parameters', asy assert.equal(chrome.storage.local.get.lastCall.args[0], 'cache:CUSTOM:["@anne",{"user":[2]}]'); }); -test('function() verifies cache with shouldRevalidate callback', async () => { +test('`updater` verifies cache with shouldRevalidate callback', async () => { createCache(10, { 'cache:@anne': 'anne@', }); @@ -298,7 +325,7 @@ test('function() verifies cache with shouldRevalidate callback', async () => { expect(spy).toHaveBeenCalledOnce(); }); -test('function() avoids concurrent function calls', async () => { +test('`updater` avoids concurrent function calls', async () => { const spy = vi.fn(getUsernameDemo); const updaterItem = new CacheItem('spy', {updater: spy}); @@ -317,7 +344,7 @@ test('function() avoids concurrent function calls', async () => { expect(spy).toHaveBeenCalledTimes(4); }); -test('function() avoids concurrent function calls with complex arguments via cacheKey', async () => { +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 CacheItem('spy', { @@ -343,7 +370,7 @@ test('function() avoids concurrent function calls with complex arguments via cac expect(spy).toHaveBeenCalledTimes(4); }); -test('function() always loads the data from storage, not memory', async () => { +test('`updater` always loads the data from storage, not memory', async () => { createCache(10, { 'cache:spy:@anne': 'ANNE', }); From 4fbdd0145d41d652ab0cfadd0a1bfe3eb79add6d Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Sat, 27 May 2023 15:37:05 +0800 Subject: [PATCH 04/27] Extract `UpdatableCacheItem` --- cache-item.test-d.js | 52 ++++++++++++++++ cache-item.test-d.ts | 75 +++++++++++++++++++++++ cache-item.ts | 141 +++++++++++++++++++++++++------------------ index.ts | 6 +- tsconfig.json | 1 + 5 files changed, 213 insertions(+), 62 deletions(-) create mode 100644 cache-item.test-d.js create mode 100644 cache-item.test-d.ts diff --git a/cache-item.test-d.js b/cache-item.test-d.js new file mode 100644 index 0000000..957aafa --- /dev/null +++ b/cache-item.test-d.js @@ -0,0 +1,52 @@ +/* eslint-disable no-new */ +import { expectType, expectNotAssignable, expectNotType } from 'tsd'; +import { CacheItem, UpdatableCacheItem } from './cache-item.js'; +const item = new CacheItem('key'); +expectType(item.isCached()); +expectType(item.delete()); +expectType(item.get()); +expectType(item.get()); +expectNotAssignable(item.get()); +expectNotType(item.set('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 }); +const itemWithUpdater = new UpdatableCacheItem('key', { + updater: async (one) => String(one).toUpperCase(), +}); +expectType(itemWithUpdater.get); +expectNotAssignable(itemWithUpdater.get); +async function identity(x) { + return x; +} +expectType(new UpdatableCacheItem('identity', { updater: identity }).get(1)); +expectType(new UpdatableCacheItem('identity', { updater: identity }).get('1')); +// @ts-expect-error -- If a function returns undefined, it's not cacheable +new UpdatableCacheItem('identity', { updater: async (n) => n[1] }); +// TODO: These expectation assertions are not working… +expectNotAssignable(new UpdatableCacheItem('identity', { updater: identity }).get(1)); +expectNotType(new UpdatableCacheItem('identity', { updater: identity }).get('1')); +new UpdatableCacheItem('number', { + updater: async (n) => Number(n), + maxAge: { days: 20 }, +}); +new UpdatableCacheItem('number', { + updater: async (n) => Number(n), + maxAge: { days: 20 }, + staleWhileRevalidate: { days: 5 }, +}); +new UpdatableCacheItem('number', { + updater: async (date) => String(date.getHours()), + cacheKey: ([date]) => date.toLocaleString(), +}); +new UpdatableCacheItem('number', { + updater: async (date) => String(date.getHours()), + shouldRevalidate: date => typeof date === 'string', +}); diff --git a/cache-item.test-d.ts b/cache-item.test-d.ts new file mode 100644 index 0000000..ece3a46 --- /dev/null +++ b/cache-item.test-d.ts @@ -0,0 +1,75 @@ +/* eslint-disable no-new */ +import {expectType, expectNotAssignable, expectNotType} from 'tsd'; +import {CacheItem, UpdatableCacheItem} from './cache-item.js'; + +type Primitive = boolean | number | string; +type Value = Primitive | Primitive[] | Record; + +const item = new CacheItem('key'); + +expectType>(item.isCached()); +expectType>(item.delete()); + +expectType>(item.get()); +expectType>(item.get()); +expectNotAssignable>(item.get()); +expectNotType>(item.set('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}); + +const itemWithUpdater = new UpdatableCacheItem('key', { + updater: async (one: number): Promise => String(one).toUpperCase(), +}); + +expectType<((n: number) => Promise)>(itemWithUpdater.get); +expectNotAssignable<((n: number) => 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 UpdatableCacheItem('identity', {updater: identity}).get(1)); +expectType>(new UpdatableCacheItem('identity', {updater: identity}).get('1')); + +// @ts-expect-error -- If a function returns undefined, it's not cacheable +new UpdatableCacheItem('identity', {updater: async (n: undefined[]) => n[1]}); + +// TODO: These expectation assertions are not working… +expectNotAssignable>(new UpdatableCacheItem('identity', {updater: identity}).get(1)); +expectNotType>(new UpdatableCacheItem('identity', {updater: identity}).get('1')); + +new UpdatableCacheItem('number', { + updater: async (n: string) => Number(n), + maxAge: {days: 20}, +}); + +new UpdatableCacheItem('number', { + updater: async (n: string) => Number(n), + maxAge: {days: 20}, + staleWhileRevalidate: {days: 5}, +}); + +new UpdatableCacheItem('number', { + updater: async (date: Date) => String(date.getHours()), + cacheKey: ([date]) => date.toLocaleString(), +}); + +new UpdatableCacheItem('number', { + updater: async (date: Date) => String(date.getHours()), + shouldRevalidate: date => typeof date === 'string', +}); diff --git a/cache-item.ts b/cache-item.ts index 0f719ac..f69be7a 100644 --- a/cache-item.ts +++ b/cache-item.ts @@ -1,88 +1,56 @@ -import {type JsonValue} from 'type-fest'; +import {type AsyncReturnType, type JsonValue} from 'type-fest'; import toMilliseconds, {type TimeDescriptor} from '@sindresorhus/to-milliseconds'; import cache, {getUserKey, type CacheKey, defaultSerializer, _get, timeInTheFuture} from './index.js'; // eslint-disable-next-line @typescript-eslint/ban-types -- It is a JSON value export type CacheValue = Exclude; -export type CacheOptions < - ScopedValue, - Updater extends ((...args: unknown[]) => Promise) = ((...args: unknown[]) => Promise), - Arguments extends unknown[] = Parameters, -> = { - maxAge?: TimeDescriptor; - staleWhileRevalidate?: TimeDescriptor; - cacheKey?: CacheKey; - shouldRevalidate?: (cachedValue: ScopedValue) => boolean; - updater?: Updater; -}; - -export default class CacheItem< - ScopedValue extends CacheValue, - Updater extends ((...args: unknown[]) => Promise) = ((...args: unknown[]) => Promise), - Arguments extends unknown[] = Parameters, -> { +export class CacheItem { readonly maxAge: TimeDescriptor; - readonly staleWhileRevalidate: TimeDescriptor; - - #cacheKey: CacheKey; - #updater: Updater | undefined; - #shouldRevalidate: ((cachedValue: ScopedValue) => boolean) | undefined; - #inFlightCache = new Map>(); - constructor( public name: string, - readonly options: CacheOptions = {}, + options: { + maxAge?: TimeDescriptor; + } = {}, ) { - this.#cacheKey = options.cacheKey ?? defaultSerializer; - this.#updater = options.updater as Updater | undefined; - this.#shouldRevalidate = options.shouldRevalidate; this.maxAge = options.maxAge ?? {days: 30}; - this.staleWhileRevalidate = options.staleWhileRevalidate ?? {days: 0}; } - async getCached(...args: Arguments) { - const userKey = getUserKey(this.name, this.#cacheKey, args); - return cache.get(userKey); + async get(): Promise { + return cache.get(this.name); } - async set(value: ScopedValue, ...args: Arguments) { + async set(value: ScopedValue): Promise { 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); + return cache.set(this.name, value, this.maxAge); } - async getFresh(...args: Arguments) { - 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)); + async delete(): Promise { + return cache.delete(this.name); } - 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; + async isCached() { + return (await this.get()) !== undefined; } +} - async get(...args: Arguments) { - if (!this.#updater) { - return this.getCached(...args); - } +export class UpdatableCacheItem< + Updater extends ((...args: any[]) => Promise), + ScopedValue extends AsyncReturnType, + Arguments extends Parameters, +> { + readonly maxAge: TimeDescriptor; + readonly staleWhileRevalidate: TimeDescriptor; + get = (async (...args: Arguments) => { const getSet = async ( userKey: string, args: Arguments, ): Promise => { - const freshValue = await this.#updater!(...args); + const freshValue = await this.#updater(...args); if (freshValue === undefined) { await cache.delete(userKey); return; @@ -90,7 +58,7 @@ export default class CacheItem< const milliseconds = toMilliseconds(this.maxAge) + toMilliseconds(this.staleWhileRevalidate); - return cache.set(userKey, freshValue, {milliseconds}); + return cache.set(userKey, freshValue, {milliseconds}) as Promise; }; const memoizeStorage = async (userKey: string, ...args: Arguments) => { @@ -108,9 +76,10 @@ export default class CacheItem< }; const userKey = getUserKey(this.name, this.#cacheKey, args); - if (this.#inFlightCache.has(userKey)) { + const cached = this.#inFlightCache.get(userKey); + if (cached) { // Avoid calling the same function twice while pending - return this.#inFlightCache.get(userKey); + return cached as Promise; } const promise = memoizeStorage(userKey, ...args); @@ -120,6 +89,60 @@ export default class CacheItem< }; promise.then(del, del); - return promise; + 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 ?? defaultSerializer; + 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 set(value: ScopedValue, ...args: Arguments) { + 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) { + 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)); + } + + 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/index.ts b/index.ts index fae1afe..883f470 100644 --- a/index.ts +++ b/index.ts @@ -28,12 +28,12 @@ type CacheItem = { type Cache = Record>; -export function getUserKey( +export function getUserKey( name: string, - cacheKey: CacheKey, + cacheKey: CacheKey | undefined, args: Arguments, ): string { - if (args.length === 0) { + if (!cacheKey || args.length === 0) { return name; } diff --git a/tsconfig.json b/tsconfig.json index 771d1ea..06a1f3b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "checkJs": true }, "files": [ + "cache-item.test-d.ts", "cache-item.ts", "index.test-d.ts", "index.ts" From be5b662d29fc15e7b0fb899befd9e92bb9327a92 Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Sat, 27 May 2023 15:48:03 +0800 Subject: [PATCH 05/27] Extract `updatable-cache-item` files --- .gitignore | 8 +- cache-item.test-d.js | 35 +--- cache-item.test-d.ts | 47 +---- cache-item.test.js | 321 +------------------------------ cache-item.ts | 116 +---------- tsconfig.json | 4 +- updatable-cache-item.test-d.ts | 47 +++++ updatable-cache-item.test.js | 342 +++++++++++++++++++++++++++++++++ updatable-cache-item.ts | 114 +++++++++++ 9 files changed, 517 insertions(+), 517 deletions(-) create mode 100644 updatable-cache-item.test-d.ts create mode 100644 updatable-cache-item.test.js create mode 100644 updatable-cache-item.ts diff --git a/.gitignore b/.gitignore index baa8bea..612d05d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ Thumbs.db *.log logs *.map -/index.js -/cache-item.js -/index.test-d.js -*.d.ts +/*.js +/*.d.ts +/*.test-d.js +!/*.test.js diff --git a/cache-item.test-d.js b/cache-item.test-d.js index 957aafa..6ba0b89 100644 --- a/cache-item.test-d.js +++ b/cache-item.test-d.js @@ -1,6 +1,5 @@ -/* eslint-disable no-new */ import { expectType, expectNotAssignable, expectNotType } from 'tsd'; -import { CacheItem, UpdatableCacheItem } from './cache-item.js'; +import { CacheItem } from './cache-item.js'; const item = new CacheItem('key'); expectType(item.isCached()); expectType(item.delete()); @@ -18,35 +17,3 @@ await item.set([true, 'string']); await item.set({ wow: [true, 'string'] }); // @ts-expect-error Type is string await item.set(1, { days: 1 }); -const itemWithUpdater = new UpdatableCacheItem('key', { - updater: async (one) => String(one).toUpperCase(), -}); -expectType(itemWithUpdater.get); -expectNotAssignable(itemWithUpdater.get); -async function identity(x) { - return x; -} -expectType(new UpdatableCacheItem('identity', { updater: identity }).get(1)); -expectType(new UpdatableCacheItem('identity', { updater: identity }).get('1')); -// @ts-expect-error -- If a function returns undefined, it's not cacheable -new UpdatableCacheItem('identity', { updater: async (n) => n[1] }); -// TODO: These expectation assertions are not working… -expectNotAssignable(new UpdatableCacheItem('identity', { updater: identity }).get(1)); -expectNotType(new UpdatableCacheItem('identity', { updater: identity }).get('1')); -new UpdatableCacheItem('number', { - updater: async (n) => Number(n), - maxAge: { days: 20 }, -}); -new UpdatableCacheItem('number', { - updater: async (n) => Number(n), - maxAge: { days: 20 }, - staleWhileRevalidate: { days: 5 }, -}); -new UpdatableCacheItem('number', { - updater: async (date) => String(date.getHours()), - cacheKey: ([date]) => date.toLocaleString(), -}); -new UpdatableCacheItem('number', { - updater: async (date) => String(date.getHours()), - shouldRevalidate: date => typeof date === 'string', -}); diff --git a/cache-item.test-d.ts b/cache-item.test-d.ts index ece3a46..15d68da 100644 --- a/cache-item.test-d.ts +++ b/cache-item.test-d.ts @@ -1,6 +1,5 @@ -/* eslint-disable no-new */ import {expectType, expectNotAssignable, expectNotType} from 'tsd'; -import {CacheItem, UpdatableCacheItem} from './cache-item.js'; +import {CacheItem} from './cache-item.js'; type Primitive = boolean | number | string; type Value = Primitive | Primitive[] | Record; @@ -29,47 +28,3 @@ await item.set({wow: [true, 'string']}); // @ts-expect-error Type is string await item.set(1, {days: 1}); - -const itemWithUpdater = new UpdatableCacheItem('key', { - updater: async (one: number): Promise => String(one).toUpperCase(), -}); - -expectType<((n: number) => Promise)>(itemWithUpdater.get); -expectNotAssignable<((n: number) => 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 UpdatableCacheItem('identity', {updater: identity}).get(1)); -expectType>(new UpdatableCacheItem('identity', {updater: identity}).get('1')); - -// @ts-expect-error -- If a function returns undefined, it's not cacheable -new UpdatableCacheItem('identity', {updater: async (n: undefined[]) => n[1]}); - -// TODO: These expectation assertions are not working… -expectNotAssignable>(new UpdatableCacheItem('identity', {updater: identity}).get(1)); -expectNotType>(new UpdatableCacheItem('identity', {updater: identity}).get('1')); - -new UpdatableCacheItem('number', { - updater: async (n: string) => Number(n), - maxAge: {days: 20}, -}); - -new UpdatableCacheItem('number', { - updater: async (n: string) => Number(n), - maxAge: {days: 20}, - staleWhileRevalidate: {days: 5}, -}); - -new UpdatableCacheItem('number', { - updater: async (date: Date) => String(date.getHours()), - cacheKey: ([date]) => date.toLocaleString(), -}); - -new UpdatableCacheItem('number', { - updater: async (date: Date) => String(date.getHours()), - shouldRevalidate: date => typeof date === 'string', -}); diff --git a/cache-item.test.js b/cache-item.test.js index 1194804..befcc80 100644 --- a/cache-item.test.js +++ b/cache-item.test.js @@ -1,9 +1,7 @@ import nodeAssert from 'node:assert'; -import {test, beforeEach, vi, assert, expect} from 'vitest'; +import {test, beforeEach, assert} from 'vitest'; import toMilliseconds from '@sindresorhus/to-milliseconds'; -import CacheItem from './cache-item.js'; - -const getUsernameDemo = async name => name.slice(1).toUpperCase(); +import {CacheItem} from './cache-item.js'; function timeInTheFuture(time) { return Date.now() + toMilliseconds(time); @@ -47,33 +45,6 @@ test('get() with expired cache', async () => { assert.equal(await testItem.get(), undefined); }); -test('getCached() with empty cache', async () => { - const spy = vi.fn(getUsernameDemo); - const testItem = new CacheItem('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 CacheItem('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 CacheItem('name', {updater: spy}); - createCache(-10, { - 'cache:name': 'Rico', - }); - assert.equal(await testItem.getCached(), undefined); - expect(spy).not.toHaveBeenCalled(); -}); - test('isCached() with empty cache', async () => { assert.equal(await testItem.isCached(), false); }); @@ -118,291 +89,3 @@ test('set() with value', async () => { assert.ok(arguments_['cache:name'].maxAge > timeInTheFuture({days: maxAge - 0.5})); assert.ok(arguments_['cache:name'].maxAge < timeInTheFuture({days: maxAge + 0.5})); }); - -test('`updater` with empty cache', async () => { - const spy = vi.fn(getUsernameDemo); - const updaterItem = new CacheItem('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 CacheItem('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 CacheItem('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 CacheItem('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 CacheItem('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 CacheItem('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 CacheItem('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 CacheItem('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 CacheItem('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 CacheItem('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 CacheItem('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 CacheItem('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 CacheItem('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` always loads the data from storage, not memory', async () => { - createCache(10, { - 'cache:spy:@anne': 'ANNE', - }); - - const spy = vi.fn(getUsernameDemo); - const updaterItem = new CacheItem('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 CacheItem('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'); -}); diff --git a/cache-item.ts b/cache-item.ts index f69be7a..36e057b 100644 --- a/cache-item.ts +++ b/cache-item.ts @@ -1,6 +1,6 @@ -import {type AsyncReturnType, type JsonValue} from 'type-fest'; -import toMilliseconds, {type TimeDescriptor} from '@sindresorhus/to-milliseconds'; -import cache, {getUserKey, type CacheKey, defaultSerializer, _get, timeInTheFuture} from './index.js'; +import {type JsonValue} from 'type-fest'; +import {type TimeDescriptor} from '@sindresorhus/to-milliseconds'; +import cache from './index.js'; // eslint-disable-next-line @typescript-eslint/ban-types -- It is a JSON value export type CacheValue = Exclude; @@ -36,113 +36,3 @@ export class CacheItem { return (await this.get()) !== undefined; } } - -export class UpdatableCacheItem< - Updater extends ((...args: any[]) => Promise), - ScopedValue extends AsyncReturnType, - Arguments extends Parameters, -> { - readonly maxAge: TimeDescriptor; - readonly staleWhileRevalidate: TimeDescriptor; - - 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 ?? defaultSerializer; - 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 set(value: ScopedValue, ...args: Arguments) { - 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) { - 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)); - } - - 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/tsconfig.json b/tsconfig.json index 06a1f3b..d0a7efb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,9 +6,11 @@ "checkJs": true }, "files": [ + "updatable-cache-item.test-d.ts", + "updatable-cache-item.ts", "cache-item.test-d.ts", "cache-item.ts", "index.test-d.ts", - "index.ts" + "index.ts", ] } diff --git a/updatable-cache-item.test-d.ts b/updatable-cache-item.test-d.ts new file mode 100644 index 0000000..0acfa2b --- /dev/null +++ b/updatable-cache-item.test-d.ts @@ -0,0 +1,47 @@ +/* eslint-disable no-new */ +import {expectType, expectNotAssignable, expectNotType} from 'tsd'; +import {UpdatableCacheItem} from './updatable-cache-item.js'; + +const itemWithUpdater = new UpdatableCacheItem('key', { + updater: async (one: number): Promise => String(one).toUpperCase(), +}); + +expectType<((n: number) => Promise)>(itemWithUpdater.get); +expectNotAssignable<((n: number) => 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 UpdatableCacheItem('identity', {updater: identity}).get(1)); +expectType>(new UpdatableCacheItem('identity', {updater: identity}).get('1')); + +// @ts-expect-error -- If a function returns undefined, it's not cacheable +new UpdatableCacheItem('identity', {updater: async (n: undefined[]) => n[1]}); + +// TODO: These expectation assertions are not working… +expectNotAssignable>(new UpdatableCacheItem('identity', {updater: identity}).get(1)); +expectNotType>(new UpdatableCacheItem('identity', {updater: identity}).get('1')); + +new UpdatableCacheItem('number', { + updater: async (n: string) => Number(n), + maxAge: {days: 20}, +}); + +new UpdatableCacheItem('number', { + updater: async (n: string) => Number(n), + maxAge: {days: 20}, + staleWhileRevalidate: {days: 5}, +}); + +new UpdatableCacheItem('number', { + updater: async (date: Date) => String(date.getHours()), + cacheKey: ([date]) => date.toLocaleString(), +}); + +new UpdatableCacheItem('number', { + updater: async (date: Date) => String(date.getHours()), + shouldRevalidate: date => typeof date === 'string', +}); diff --git a/updatable-cache-item.test.js b/updatable-cache-item.test.js new file mode 100644 index 0000000..6420fa5 --- /dev/null +++ b/updatable-cache-item.test.js @@ -0,0 +1,342 @@ +import {test, beforeEach, vi, assert, expect} from 'vitest'; +import toMilliseconds from '@sindresorhus/to-milliseconds'; +import {UpdatableCacheItem} from './updatable-cache-item.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}), + }}); + } +} + +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 UpdatableCacheItem('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 UpdatableCacheItem('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 UpdatableCacheItem('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 UpdatableCacheItem('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 UpdatableCacheItem('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 UpdatableCacheItem('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 UpdatableCacheItem('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 UpdatableCacheItem('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 UpdatableCacheItem('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 UpdatableCacheItem('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 UpdatableCacheItem('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 UpdatableCacheItem('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 UpdatableCacheItem('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 UpdatableCacheItem('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 UpdatableCacheItem('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 UpdatableCacheItem('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` always loads the data from storage, not memory', async () => { + createCache(10, { + 'cache:spy:@anne': 'ANNE', + }); + + const spy = vi.fn(getUsernameDemo); + const updaterItem = new UpdatableCacheItem('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 UpdatableCacheItem('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'); +}); diff --git a/updatable-cache-item.ts b/updatable-cache-item.ts new file mode 100644 index 0000000..a2e7465 --- /dev/null +++ b/updatable-cache-item.ts @@ -0,0 +1,114 @@ +import {type AsyncReturnType} from 'type-fest'; +import toMilliseconds, {type TimeDescriptor} from '@sindresorhus/to-milliseconds'; +import {type CacheValue} from './cache-item.js'; +import cache, {getUserKey, type CacheKey, defaultSerializer, _get, timeInTheFuture} from './index.js'; + +export class UpdatableCacheItem< + Updater extends ((...args: any[]) => Promise), + ScopedValue extends AsyncReturnType, + Arguments extends Parameters, +> { + readonly maxAge: TimeDescriptor; + readonly staleWhileRevalidate: TimeDescriptor; + + 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 ?? defaultSerializer; + 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 set(value: ScopedValue, ...args: Arguments) { + 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) { + 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)); + } + + 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; + } +} From 6dc9619f8f1b27a84c5a397417d643c6d1212fd1 Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Sat, 27 May 2023 16:07:54 +0800 Subject: [PATCH 06/27] Move files to /source/; rename index to legacy --- .gitignore | 6 +----- cache-item.test-d.js | 19 ------------------- package.json | 16 +++++++++++++--- .../cache-item.test-d.ts | 10 +++++----- .../cache-item.test.js | 3 ++- cache-item.ts => source/cache-item.ts | 4 ++-- source/index.ts | 3 +++ index.test-d.ts => source/legacy.test-d.ts | 2 +- index.test.js => source/legacy.test.js | 3 ++- index.ts => source/legacy.ts | 0 .../updatable-cache-item.test-d.ts | 9 ++++----- .../updatable-cache-item.test.js | 3 ++- .../updatable-cache-item.ts | 4 ++-- tsconfig.json | 14 ++++---------- vite.config.ts | 2 +- test/_setup.js => vitest.setup.js | 0 16 files changed, 42 insertions(+), 56 deletions(-) delete mode 100644 cache-item.test-d.js rename cache-item.test-d.ts => source/cache-item.test-d.ts (75%) rename cache-item.test.js => source/cache-item.test.js (94%) rename cache-item.ts => source/cache-item.ts (89%) create mode 100644 source/index.ts rename index.test-d.ts => source/legacy.test-d.ts (98%) rename index.test.js => source/legacy.test.js (98%) rename index.ts => source/legacy.ts (100%) rename updatable-cache-item.test-d.ts => source/updatable-cache-item.test-d.ts (82%) rename updatable-cache-item.test.js => source/updatable-cache-item.test.js (98%) rename updatable-cache-item.ts => source/updatable-cache-item.ts (97%) rename test/_setup.js => vitest.setup.js (100%) diff --git a/.gitignore b/.gitignore index 612d05d..7d8022d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,4 @@ Thumbs.db *.bak *.log logs -*.map -/*.js -/*.d.ts -/*.test-d.js -!/*.test.js +distribution diff --git a/cache-item.test-d.js b/cache-item.test-d.js deleted file mode 100644 index 6ba0b89..0000000 --- a/cache-item.test-d.js +++ /dev/null @@ -1,19 +0,0 @@ -import { expectType, expectNotAssignable, expectNotType } from 'tsd'; -import { CacheItem } from './cache-item.js'; -const item = new CacheItem('key'); -expectType(item.isCached()); -expectType(item.delete()); -expectType(item.get()); -expectType(item.get()); -expectNotAssignable(item.get()); -expectNotType(item.set('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/package.json b/package.json index 60ba753..790bfa0 100644 --- a/package.json +++ b/package.json @@ -28,10 +28,17 @@ "Connor Love" ], "type": "module", - "exports": "./index.js", + "types": "./distribution/index.d.ts", + "exports": "./distribution/index.js", "files": [ - "index.js", - "index.d.ts" + "distribution/index.js", + "distribution/index.d.ts", + "distribution/legacy.js", + "distribution/legacy.d.ts", + "distribution/cache-item.js", + "distribution/cache-item.d.ts", + "distribution/updatable-cache-item.js", + "distribution/updatable-cache-item.d.ts" ], "scripts": { "build": "tsc", @@ -39,6 +46,9 @@ "test": "tsc && tsd && vitest && xo", "watch": "tsc --watch" }, + "tsd": { + "directory": "source" + }, "xo": { "envs": [ "browser", diff --git a/cache-item.test-d.ts b/source/cache-item.test-d.ts similarity index 75% rename from cache-item.test-d.ts rename to source/cache-item.test-d.ts index 15d68da..8950b38 100644 --- a/cache-item.test-d.ts +++ b/source/cache-item.test-d.ts @@ -1,5 +1,5 @@ -import {expectType, expectNotAssignable, expectNotType} from 'tsd'; -import {CacheItem} from './cache-item.js'; +import {expectType, expectNotAssignable, expectAssignable} from 'tsd'; +import CacheItem from './cache-item.js'; type Primitive = boolean | number | string; type Value = Primitive | Primitive[] | Record; @@ -9,10 +9,10 @@ const item = new CacheItem('key'); expectType>(item.isCached()); expectType>(item.delete()); -expectType>(item.get()); -expectType>(item.get()); +expectAssignable>(item.get()); expectNotAssignable>(item.get()); -expectNotType>(item.set('string')); +expectType>(item.get()); +expectType>(item.set('some string')); // @ts-expect-error Type is string await item.set(1); diff --git a/cache-item.test.js b/source/cache-item.test.js similarity index 94% rename from cache-item.test.js rename to source/cache-item.test.js index befcc80..10a33a0 100644 --- a/cache-item.test.js +++ b/source/cache-item.test.js @@ -1,7 +1,8 @@ +/* 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 {CacheItem} from './cache-item.js'; +import CacheItem from './cache-item.ts'; function timeInTheFuture(time) { return Date.now() + toMilliseconds(time); diff --git a/cache-item.ts b/source/cache-item.ts similarity index 89% rename from cache-item.ts rename to source/cache-item.ts index 36e057b..e8e5953 100644 --- a/cache-item.ts +++ b/source/cache-item.ts @@ -1,11 +1,11 @@ import {type JsonValue} from 'type-fest'; import {type TimeDescriptor} from '@sindresorhus/to-milliseconds'; -import cache from './index.js'; +import cache from './legacy.js'; // eslint-disable-next-line @typescript-eslint/ban-types -- It is a JSON value export type CacheValue = Exclude; -export class CacheItem { +export default class CacheItem { readonly maxAge: TimeDescriptor; constructor( public name: string, diff --git a/source/index.ts b/source/index.ts new file mode 100644 index 0000000..a946afb --- /dev/null +++ b/source/index.ts @@ -0,0 +1,3 @@ +export {default as legacyCache} from './legacy.js'; +export {default as CacheItem} from './cache-item.js'; +export {default as UpdatableCacheItem} from './updatable-cache-item.js'; diff --git a/index.test-d.ts b/source/legacy.test-d.ts similarity index 98% rename from index.test-d.ts rename to source/legacy.test-d.ts index 207e1cb..606f69f 100644 --- a/index.test-d.ts +++ b/source/legacy.test-d.ts @@ -1,5 +1,5 @@ import {expectType, expectNotAssignable, expectAssignable} from 'tsd'; -import cache from './index.js'; +import cache from './legacy.js'; type Primitive = boolean | number | string; type Value = Primitive | Primitive[] | Record; diff --git a/index.test.js b/source/legacy.test.js similarity index 98% rename from index.test.js rename to source/legacy.test.js index 37cc033..e4ebb3b 100644 --- a/index.test.js +++ b/source/legacy.test.js @@ -1,7 +1,8 @@ +/* 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, vi, assert, expect} from 'vitest'; import toMilliseconds from '@sindresorhus/to-milliseconds'; -import cache from './index.js'; +import cache from './legacy.ts'; // Help migration away from AVA const t = { diff --git a/index.ts b/source/legacy.ts similarity index 100% rename from index.ts rename to source/legacy.ts diff --git a/updatable-cache-item.test-d.ts b/source/updatable-cache-item.test-d.ts similarity index 82% rename from updatable-cache-item.test-d.ts rename to source/updatable-cache-item.test-d.ts index 0acfa2b..b31318a 100644 --- a/updatable-cache-item.test-d.ts +++ b/source/updatable-cache-item.test-d.ts @@ -1,13 +1,13 @@ /* eslint-disable no-new */ import {expectType, expectNotAssignable, expectNotType} from 'tsd'; -import {UpdatableCacheItem} from './updatable-cache-item.js'; +import UpdatableCacheItem from './updatable-cache-item.js'; const itemWithUpdater = new UpdatableCacheItem('key', { updater: async (one: number): Promise => String(one).toUpperCase(), }); expectType<((n: number) => Promise)>(itemWithUpdater.get); -expectNotAssignable<((n: number) => Promise)>(itemWithUpdater.get); +expectNotAssignable<((n: string) => Promise)>(itemWithUpdater.get); async function identity(x: string): Promise; async function identity(x: number): Promise; @@ -21,9 +21,8 @@ expectType>(new UpdatableCacheItem('identity', {updater: identit // @ts-expect-error -- If a function returns undefined, it's not cacheable new UpdatableCacheItem('identity', {updater: async (n: undefined[]) => n[1]}); -// TODO: These expectation assertions are not working… -expectNotAssignable>(new UpdatableCacheItem('identity', {updater: identity}).get(1)); -expectNotType>(new UpdatableCacheItem('identity', {updater: identity}).get('1')); +expectNotAssignable>(new UpdatableCacheItem('identity', {updater: identity}).get(1)); +expectNotType>(new UpdatableCacheItem('identity', {updater: identity}).get('1')); new UpdatableCacheItem('number', { updater: async (n: string) => Number(n), diff --git a/updatable-cache-item.test.js b/source/updatable-cache-item.test.js similarity index 98% rename from updatable-cache-item.test.js rename to source/updatable-cache-item.test.js index 6420fa5..6988668 100644 --- a/updatable-cache-item.test.js +++ b/source/updatable-cache-item.test.js @@ -1,6 +1,7 @@ +/* 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 {UpdatableCacheItem} from './updatable-cache-item.js'; +import UpdatableCacheItem from './updatable-cache-item.ts'; const getUsernameDemo = async name => name.slice(1).toUpperCase(); diff --git a/updatable-cache-item.ts b/source/updatable-cache-item.ts similarity index 97% rename from updatable-cache-item.ts rename to source/updatable-cache-item.ts index a2e7465..6e2b0e6 100644 --- a/updatable-cache-item.ts +++ b/source/updatable-cache-item.ts @@ -1,9 +1,9 @@ import {type AsyncReturnType} from 'type-fest'; import toMilliseconds, {type TimeDescriptor} from '@sindresorhus/to-milliseconds'; import {type CacheValue} from './cache-item.js'; -import cache, {getUserKey, type CacheKey, defaultSerializer, _get, timeInTheFuture} from './index.js'; +import cache, {getUserKey, type CacheKey, defaultSerializer, _get, timeInTheFuture} from './legacy.js'; -export class UpdatableCacheItem< +export default class UpdatableCacheItem< Updater extends ((...args: any[]) => Promise), ScopedValue extends AsyncReturnType, Arguments extends Parameters, diff --git a/tsconfig.json b/tsconfig.json index d0a7efb..6ac48fb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,16 +1,10 @@ { "extends": "@sindresorhus/tsconfig", "compilerOptions": { - "outDir": ".", - "target": "ES2022", - "checkJs": true + "outDir": "distribution", + "target": "ES2022" }, - "files": [ - "updatable-cache-item.test-d.ts", - "updatable-cache-item.ts", - "cache-item.test-d.ts", - "cache-item.ts", - "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 From c1dd1c17562e09e6cce55312e84104308f5e29e7 Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Sat, 27 May 2023 16:09:24 +0800 Subject: [PATCH 07/27] Allow direct legacy import --- package.json | 5 ++++- source/index.ts | 1 - 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 790bfa0..3f51ca9 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,10 @@ ], "type": "module", "types": "./distribution/index.d.ts", - "exports": "./distribution/index.js", + "exports": { + ".": "./distribution/index.js", + "legacy.js": "./distribution/legacy.js" + }, "files": [ "distribution/index.js", "distribution/index.d.ts", diff --git a/source/index.ts b/source/index.ts index a946afb..1e227b7 100644 --- a/source/index.ts +++ b/source/index.ts @@ -1,3 +1,2 @@ -export {default as legacyCache} from './legacy.js'; export {default as CacheItem} from './cache-item.js'; export {default as UpdatableCacheItem} from './updatable-cache-item.js'; From 6dc3272c84eb5be70ff565a057ba8821b65dc620 Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Sat, 27 May 2023 16:48:58 +0800 Subject: [PATCH 08/27] Readme 1/? --- readme.md | 174 ++++++++++++++++++++++++++++++------------------ source/index.ts | 6 ++ 2 files changed, 117 insertions(+), 63 deletions(-) diff --git a/readme.md b/readme.md index f970de5..16f8669 100644 --- a/readme.md +++ b/readme.md @@ -6,7 +6,7 @@ This module works on content scripts, background pages and option pages. ## 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 +14,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 +35,31 @@ This module requires the `storage` permission and it’s suggested to also use ` ``` ```js -import cache from 'webext-storage-cache'; +import {CacheItem} from 'webext-storage-cache'; + +const item = new CacheItem('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 `UpdatableCacheItem`: ```js -import cache from 'webext-storage-cache'; +import {UpdatableCacheItem} from 'webext-storage-cache'; -const cachedFunction = cache.function(someFunction, { - name: 'unique', +const item = new CacheItem('unique', { + updater: someFunction, maxAge: { days: 3, }, @@ -73,104 +72,141 @@ 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. - -### cache.has(key) +### new CacheItem(key, options) -Checks if the given key is in the cache, returns a `boolean`. +This class lets you manage a specific value in the cache, preserving its type if you're using TypeScript ```js -const isCached = await cache.has('cached-url'); -// true or false +import {CacheItem} from 'webext-storage-cache'; + +const url = new CacheItem('cached-url'); + +// Or in TypeScript +const url = new CacheItem('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 CacheItem('stars', {days: 1}); +const starCount = new CacheItem('stars'); // Bad: they will override each other ``` #### key -Type: `string` +Type: string -### cache.get(key) +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. + + +### CacheItem#get() Returns the cached value of key if it exists and hasn't expired, returns `undefined` otherwise. ```js -const url = await cache.get('cached-url'); +const cache = new CacheItem('cached-url'); +const url = await cache.get(); // It will be `undefined` if it's not found. ``` -#### key - -Type: `string` - -### cache.set(key, value, maxAge) +### CacheItem#set(value) -Caches the given key and value for a given amount of time. It returns the value itself. +Caches the value for the amount of time specified in the `CacheItem` constructor. It returns the value itself. ```js +const cache = new CacheItem('core-info'); const info = await getInfoObject(); -await cache.set('core-info', info); // Cached for 30 days by default +await cache.set(info); // Cached for 30 days by default ``` -#### key - -Type: `string` - #### 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 `cache.delete(key)` instead +`undefined` will remove the cached item. For this purpose it's best to use [`CacheItem#delete()`](#cacheitem-delete) instead -#### maxAge +### CacheItem#isCached() -Type: [`TimeDescriptor`](https://github.com/sindresorhus/to-milliseconds#input)
-Default: `{days: 30}` +Checks whether the item is in the cache, returns a `boolean`. -The amount of time after which the cache item will expire. +```js +const url = new CacheItem('url'); +const isCached = await url.isCached(); +// true or false +``` -### cache.delete(key) +### CacheItem.delete() Deletes the requested item from the cache. ```js -await cache.delete('cached-url'); +const url = new CacheItem('url'); + +await url.set('https://github.com'); +console.log(await url.isCached()); // true + +await url.delete(); +console.log(await url.isCached()); // false ``` -#### key +### UpdatableCacheItem(key, options) -Type: `string` +You can think of `UpdatableCacheItem` as an advanced "memoize" function that you can call with any arguments, but also: -### cache.clear() +- 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()`) -Deletes the entire cache. +#### key -```js -await cache.clear(); -``` +Type: string -### cache.function(getter, options) +The unique name that will be used in `chrome.storage.local` combined with the function arguments, like `cache:${key}:{arguments}`. -Caches the return value of the function based on the `cacheKey`. It works similarly to a memoization function: +For example, these two calls: ```js -async function getHTML(url, options) { - const response = await fetch(url, options); - return response.text(); -} +const pages = new UpdatableCacheItem('pages', {updater: fetchText}); -const cachedGetHTML = cache.function(getHTML, {name: 'html'}); +await pages.get('./contacts'); +await pages.get('./about'); +``` -const html = await cachedGetHTML('https://google.com', {}); -// The HTML of google.com will be saved with the key 'https://google.com' +Will create two items in the cache: -const freshHtml = await cachedGetHTML.fresh('https://google.com', {}); -// Escape hatch to ignore memoization and force a refresh of the cache +```json +{ + "cache:pages:./contacts": "You're on the contacts page", + "cache:pages:./about": "You're on the about page" +} ``` -#### getter +#### options + +#### updater +Required.
Type: `async function` that returns a cacheable value. -Returning `undefined` will skip the cache, just like `cache.set()`. +Returning `undefined` will make the cache, just like `cache.set()`. + +##### 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. #### options @@ -270,6 +306,18 @@ const json = await cachedGetHTML('https://google.com'); // The HTML of google.com will be saved with the key 'https://google.com' ``` +### globalCache.clear() + +Clears the cache. This is a special method that acts on the entire cache of the extension. + +```js +import {globalCache} from 'webext-storage-cache'; + +document.querySelector('.options .clear-cache').addEventListener('click', async () => { + await globalCache.clear() +}) +``` + ## Related - [webext-detect-page](https://github.com/fregante/webext-detect-page) - Detects where the current browser extension code is being run. diff --git a/source/index.ts b/source/index.ts index 1e227b7..8d0377c 100644 --- a/source/index.ts +++ b/source/index.ts @@ -1,2 +1,8 @@ +import cache from './legacy.js' + export {default as CacheItem} from './cache-item.js'; export {default as UpdatableCacheItem} from './updatable-cache-item.js'; + +export const globalCache = { + clear: cache.clear, +} From 49ec410f9eba15dcf97c10aaea90b603a4e7e426 Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Sat, 27 May 2023 17:00:34 +0800 Subject: [PATCH 09/27] Drop `cache.function` --- source/index.ts | 4 +- source/legacy.test-d.ts | 49 ------- source/legacy.test.js | 276 +--------------------------------------- source/legacy.ts | 71 +---------- 4 files changed, 4 insertions(+), 396 deletions(-) diff --git a/source/index.ts b/source/index.ts index 8d0377c..92b2419 100644 --- a/source/index.ts +++ b/source/index.ts @@ -1,8 +1,8 @@ -import cache from './legacy.js' +import cache from './legacy.js'; export {default as CacheItem} from './cache-item.js'; export {default as UpdatableCacheItem} from './updatable-cache-item.js'; export const globalCache = { clear: cache.clear, -} +}; diff --git a/source/legacy.test-d.ts b/source/legacy.test-d.ts index 606f69f..90354de 100644 --- a/source/legacy.test-d.ts +++ b/source/legacy.test-d.ts @@ -16,52 +16,3 @@ 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/source/legacy.test.js b/source/legacy.test.js index e4ebb3b..fb57c70 100644 --- a/source/legacy.test.js +++ b/source/legacy.test.js @@ -1,6 +1,6 @@ /* 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, vi, assert, expect} from 'vitest'; +import {test, beforeEach, assert} from 'vitest'; import toMilliseconds from '@sindresorhus/to-milliseconds'; import cache from './legacy.ts'; @@ -13,8 +13,6 @@ const t = { throwsAsync: nodeAssert.rejects, }; -const getUsernameDemo = async name => name.slice(1).toUpperCase(); - function timeInTheFuture(time) { return Date.now() + toMilliseconds(time); } @@ -96,275 +94,3 @@ test('set() with value', async () => { 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/source/legacy.ts b/source/legacy.ts index 883f470..fec335a 100644 --- a/source/legacy.ts +++ b/source/legacy.ts @@ -138,81 +138,12 @@ export type MemoizedFunctionOptions = 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 CacheItem and UpdatableCacheItem instead */ const cache = { has, get, set, clear, - function: function_, delete: delete_, }; From c366f579f04b2db2c9aa66eb6047774861a275d6 Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Sat, 27 May 2023 17:20:16 +0800 Subject: [PATCH 10/27] Readme 2/2; change defaultCacheKey --- readme.md | 113 ++++++++++++---------------- source/legacy.ts | 8 -- source/updatable-cache-item.test.js | 60 +++++++-------- source/updatable-cache-item.ts | 4 +- 4 files changed, 81 insertions(+), 104 deletions(-) diff --git a/readme.md b/readme.md index 16f8669..34626c3 100644 --- a/readme.md +++ b/readme.md @@ -108,7 +108,6 @@ Default: `{days: 30}` The amount of time after which the cache item will expire after being each `.set()` call. - ### CacheItem#get() Returns the cached value of key if it exists and hasn't expired, returns `undefined` otherwise. @@ -179,27 +178,29 @@ For example, these two calls: ```js const pages = new UpdatableCacheItem('pages', {updater: fetchText}); +await pages.get(); await pages.get('./contacts'); -await pages.get('./about'); +await pages.get('./about', 2); ``` Will create two items in the cache: ```json { - "cache:pages:./contacts": "You're on the contacts page", - "cache:pages:./about": "You're on the about page" + "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 +##### updater Required.
Type: `async function` that returns a cacheable value. -Returning `undefined` will make the cache, just like `cache.set()`. +Returning `undefined` will delete the item from the cache. ##### maxAge @@ -208,60 +209,16 @@ Default: `{days: 30}` The amount of time after which the cache item will expire after being each `.set()` call. -#### 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. +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 cachedOperate = cache.function(operate, { - name: 'operate', +const operate = new UpdatableCacheItem('posts', { + updater: operate, maxAge: { days: 10, }, @@ -270,22 +227,22 @@ const cachedOperate = cache.function(operate, { }, }); -cachedOperate(); // It will run `operate` and cache it for 10 days -cachedOperate(); // It will return the cache +await operate.get(); // It will run `operate` and cache it for 10 days +await operate.get(); // It will return the cache -/* 11 days later, cache is expired, but still there */ +/* 3 days later, cache is expired, but still there */ -cachedOperate(); // It will return the cache +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 */ -cachedOperate(); // It will run `operate` and cache it for 10 days +await operate.get(); // It will run `operate` and cache it for 10 days ``` ##### shouldRevalidate -Type: `function` that returns a boolean
+Type: `(cachedValue) => boolean`
Default: `() => false` You may want to have additional checks on the cached value, for example after updating its format. @@ -296,19 +253,47 @@ async function getContent(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', +const content = new UpdatableCacheItem('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 cachedGetHTML('https://google.com'); -// The HTML of google.com will be saved with the key 'https://google.com' +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 UpdatableCacheItem('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 UpdatableCacheItem('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' ``` ### globalCache.clear() -Clears the cache. This is a special method that acts on the entire cache of the extension. +Clears the cache. This is a special method that acts on the entire cache of the extension. ```js import {globalCache} from 'webext-storage-cache'; diff --git a/source/legacy.ts b/source/legacy.ts index fec335a..b36deb9 100644 --- a/source/legacy.ts +++ b/source/legacy.ts @@ -8,14 +8,6 @@ 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 diff --git a/source/updatable-cache-item.test.js b/source/updatable-cache-item.test.js index 6988668..2edf970 100644 --- a/source/updatable-cache-item.test.js +++ b/source/updatable-cache-item.test.js @@ -60,14 +60,14 @@ test('`updater` with empty cache', async () => { assert.equal(await updaterItem.get('@anne'), 'ANNE'); - assert.equal(chrome.storage.local.get.lastCall.args[0], 'cache:spy:@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'); + 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', + 'cache:spy:["@anne"]': 'ANNE', }); const spy = vi.fn(getUsernameDemo); @@ -75,23 +75,23 @@ test('`updater` with cache', async () => { assert.equal(await updaterItem.get('@anne'), 'ANNE'); - assert.equal(chrome.storage.local.get.lastCall.args[0], 'cache:spy:@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', + 'cache:spy:["@anne"]': 'ONNA-expired-name', }); const spy = vi.fn(getUsernameDemo); const updaterItem = new UpdatableCacheItem('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.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'); + assert.equal(chrome.storage.local.set.lastCall.args[0]['cache:spy:["@anne"]'].data, 'ANNE'); }); test('`updater` with empty cache and staleWhileRevalidate', async () => { @@ -107,20 +107,20 @@ test('`updater` with empty cache and staleWhileRevalidate', async () => { assert.equal(await updaterItem.get('@anne'), 'ANNE'); - assert.equal(chrome.storage.local.get.lastCall.args[0], 'cache:spy:@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'); + 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})); + 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', + 'cache:spy:["@anne"]': 'ANNE', }); const spy = vi.fn(getUsernameDemo); @@ -146,7 +146,7 @@ test('`updater` with fresh cache and staleWhileRevalidate', async () => { test('`updater` with stale cache and staleWhileRevalidate', async () => { createCache(15, { - 'cache:spy:@anne': 'ANNE', + 'cache:spy:["@anne"]': 'ANNE', }); const spy = vi.fn(getUsernameDemo); @@ -158,7 +158,7 @@ test('`updater` with stale cache and staleWhileRevalidate', async () => { assert.equal(await updaterItem.get('@anne'), 'ANNE'); - assert.equal(chrome.storage.local.get.lastCall.args[0], 'cache:spy:@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 @@ -171,12 +171,12 @@ test('`updater` with stale cache and staleWhileRevalidate', async () => { // 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'); + 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', + 'cache:spy:["@anne"]': 'ANNE', }); const spy = vi.fn(getUsernameDemo); @@ -191,20 +191,20 @@ test('`updater` varies cache by function argument', async () => { test('`updater` accepts custom cache key generator', async () => { createCache(10, { - 'cache:spy:@anne,1': 'ANNE,1', + 'cache:spy:["@anne",1]': 'ANNE,1', }); const spy = vi.fn(getUsernameDemo); const updaterItem = new UpdatableCacheItem('spy', {updater: spy}); - await updaterItem.get('@anne', '1'); + await updaterItem.get('@anne', 1); expect(spy).not.toHaveBeenCalled(); - await updaterItem.get('@anne', '2'); + 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'); + 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 () => { @@ -255,8 +255,8 @@ test('`updater` verifies cache with shouldRevalidate callback', async () => { }); 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'); + 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(); }); @@ -307,7 +307,7 @@ test('`updater` avoids concurrent function calls with complex arguments via cach test('`updater` always loads the data from storage, not memory', async () => { createCache(10, { - 'cache:spy:@anne': 'ANNE', + 'cache:spy:["@anne"]': 'ANNE', }); const spy = vi.fn(getUsernameDemo); @@ -316,21 +316,21 @@ test('`updater` always loads the data from storage, not memory', async () => { 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'); + assert.equal(chrome.storage.local.get.lastCall.args[0], 'cache:spy:["@anne"]'); createCache(10, { - 'cache:spy:@anne': 'NEW ANNE', + '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'); + 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', + 'cache:spy:["@anne"]': 'OVERWRITE_ME', }); const spy = vi.fn(getUsernameDemo); @@ -339,5 +339,5 @@ test('.getFresh() ignores cached value', async () => { 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'); + assert.equal(chrome.storage.local.set.lastCall.args[0]['cache:spy:["@anne"]'].data, 'ANNE'); }); diff --git a/source/updatable-cache-item.ts b/source/updatable-cache-item.ts index 6e2b0e6..97dd1c1 100644 --- a/source/updatable-cache-item.ts +++ b/source/updatable-cache-item.ts @@ -1,7 +1,7 @@ import {type AsyncReturnType} from 'type-fest'; import toMilliseconds, {type TimeDescriptor} from '@sindresorhus/to-milliseconds'; import {type CacheValue} from './cache-item.js'; -import cache, {getUserKey, type CacheKey, defaultSerializer, _get, timeInTheFuture} from './legacy.js'; +import cache, {getUserKey, type CacheKey, _get, timeInTheFuture} from './legacy.js'; export default class UpdatableCacheItem< Updater extends ((...args: any[]) => Promise), @@ -73,7 +73,7 @@ export default class UpdatableCacheItem< shouldRevalidate?: (cachedValue: ScopedValue) => boolean; }, ) { - this.#cacheKey = options.cacheKey ?? defaultSerializer; + this.#cacheKey = options.cacheKey ?? JSON.stringify; this.#updater = options.updater; this.#shouldRevalidate = options.shouldRevalidate; this.maxAge = options.maxAge ?? {days: 30}; From 053c2b158d512d0a22f5059f023f9b7c9876b157 Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Sat, 27 May 2023 17:26:16 +0800 Subject: [PATCH 11/27] Extract 2 documentation files --- readme.md | 221 +-------------------------------- source/cache-item.md | 91 ++++++++++++++ source/updatable-cache-item.md | 138 ++++++++++++++++++++ 3 files changed, 232 insertions(+), 218 deletions(-) create mode 100644 source/cache-item.md create mode 100644 source/updatable-cache-item.md diff --git a/readme.md b/readme.md index 34626c3..7289db9 100644 --- a/readme.md +++ b/readme.md @@ -72,224 +72,9 @@ const item = new CacheItem('unique', { ## API -### new CacheItem(key, options) - -This class lets you manage a specific value in the cache, preserving its type if you're using TypeScript - -```js -import {CacheItem} from 'webext-storage-cache'; - -const url = new CacheItem('cached-url'); - -// Or in TypeScript -const url = new CacheItem('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 CacheItem('stars', {days: 1}); -const starCount = new CacheItem('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. - -### CacheItem#get() - -Returns the cached value of key if it exists and hasn't expired, returns `undefined` otherwise. - -```js -const cache = new CacheItem('cached-url'); -const url = await cache.get(); -// It will be `undefined` if it's not found. -``` - -### CacheItem#set(value) - -Caches the value for the amount of time specified in the `CacheItem` constructor. It returns the value itself. - -```js -const cache = new CacheItem('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 [`CacheItem#delete()`](#cacheitem-delete) instead - -### CacheItem#isCached() - -Checks whether the item is in the cache, returns a `boolean`. - -```js -const url = new CacheItem('url'); -const isCached = await url.isCached(); -// true or false -``` - -### CacheItem.delete() - -Deletes the requested item from the cache. - -```js -const url = new CacheItem('url'); - -await url.set('https://github.com'); -console.log(await url.isCached()); // true - -await url.delete(); -console.log(await url.isCached()); // false -``` - -### UpdatableCacheItem(key, options) - -You can think of `UpdatableCacheItem` 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 UpdatableCacheItem('pages', {updater: fetchText}); - -await pages.get(); -await pages.get('./contacts'); -await pages.get('./about', 2); -``` - -Will create two items in the cache: - -```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 `.set()` call. - -##### 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 UpdatableCacheItem('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 UpdatableCacheItem('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 UpdatableCacheItem('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 UpdatableCacheItem('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' -``` +- [CacheItem](./source/cache-item.md) - A simple API getter/setter +- [UpdatableCacheItem](./source/updatable-cache-item.md) - A memoize-like API to cache your function calls without manually calling `isCached`/`get`/`set` +- `globalCache` - Global helpers, documented below ### globalCache.clear() diff --git a/source/cache-item.md b/source/cache-item.md new file mode 100644 index 0000000..6c2395b --- /dev/null +++ b/source/cache-item.md @@ -0,0 +1,91 @@ +_Go back to the [main documentation page.](../readme.md#api)_ + +# new CacheItem(key, options) + +This class lets you manage a specific value in the cache, preserving its type if you're using TypeScript: + +```js +import {CacheItem} from 'webext-storage-cache'; + +const url = new CacheItem('cached-url'); + +// Or in TypeScript +const url = new CacheItem('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 CacheItem('stars', {days: 1}); +const starCount = new CacheItem('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. + +# CacheItem#get() + +Returns the cached value of key if it exists and hasn't expired, returns `undefined` otherwise. + +```js +const cache = new CacheItem('cached-url'); +const url = await cache.get(); +// It will be `undefined` if it's not found. +``` + +# CacheItem#set(value) + +Caches the value for the amount of time specified in the `CacheItem` constructor. It returns the value itself. + +```js +const cache = new CacheItem('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 [`CacheItem#delete()`](#cacheitem-delete) instead + +# CacheItem#isCached() + +Checks whether the item is in the cache, returns a `boolean`. + +```js +const url = new CacheItem('url'); +const isCached = await url.isCached(); +// true or false +``` + +# CacheItem.delete() + +Deletes the requested item from the cache. + +```js +const url = new CacheItem('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/updatable-cache-item.md b/source/updatable-cache-item.md new file mode 100644 index 0000000..51f9ec4 --- /dev/null +++ b/source/updatable-cache-item.md @@ -0,0 +1,138 @@ +_Go back to the [main documentation page.](../readme.md#api)_ + +# UpdatableCacheItem(key, options) + +You can think of `UpdatableCacheItem` 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 UpdatableCacheItem('pages', {updater: fetchText}); + +await pages.get(); +await pages.get('./contacts'); +await pages.get('./about', 2); +``` + +Will create two items in the cache: + +```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 `.set()` call. + +### 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 UpdatableCacheItem('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 UpdatableCacheItem('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 UpdatableCacheItem('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 UpdatableCacheItem('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' +``` + +## License + +MIT © [Federico Brigante](https://fregante.com) From 40e5571caedf1dbe730c43f1103f0807c1971c0a Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Sat, 27 May 2023 17:35:28 +0800 Subject: [PATCH 12/27] Document legacy API --- readme.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/readme.md b/readme.md index 7289db9..eda6f20 100644 --- a/readme.md +++ b/readme.md @@ -75,6 +75,7 @@ const item = new CacheItem('unique', { - [CacheItem](./source/cache-item.md) - A simple API getter/setter - [UpdatableCacheItem](./source/updatable-cache-item.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 ### globalCache.clear() @@ -88,6 +89,27 @@ document.querySelector('.options .clear-cache').addEventListener('click', async }) ``` +### legacy API + +The API used until v5 has been deprecated and you should migrate to: + +- `CacheItem` for simple `cache.get`/`cache.set` calls. This API makes more sense in a typed context because the type is preserved/enforced across calls. +- `UpdatableCacheItem` for `cache.function`. It behaves in a similar fashion, but also has extra methods like `getCached` and it's a lot safer to use. + +You can: + +- [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` + +```js +import cache from "webext-storage-cache/legacy.js"; + +await cache.get('my-url'); +await cache.set('my-url', 'https://example.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 - [webext-detect-page](https://github.com/fregante/webext-detect-page) - Detects where the current browser extension code is being run. From f53584c9c02281587a04d80c325ca6321544bf5a Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Sat, 27 May 2023 18:22:59 +0800 Subject: [PATCH 13/27] Document `UpdatableCacheItem` methods --- package.json | 6 ++-- readme.md | 11 +++++-- source/updatable-cache-item.md | 59 +++++++++++++++++++++++++++++++++- source/updatable-cache-item.ts | 1 + 4 files changed, 71 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 3f51ca9..6d2bb12 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", + "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", diff --git a/readme.md b/readme.md index eda6f20..8dd9826 100644 --- a/readme.md +++ b/readme.md @@ -1,8 +1,13 @@ # 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 - -This module works on content scripts, background pages and option pages. +> Cache values in your Web Extension and clear them on expiration. Also includes a memoize-like API to cache any function results automatically. + +- 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 diff --git a/source/updatable-cache-item.md b/source/updatable-cache-item.md index 51f9ec4..c420a11 100644 --- a/source/updatable-cache-item.md +++ b/source/updatable-cache-item.md @@ -23,9 +23,10 @@ const pages = new UpdatableCacheItem('pages', {updater: fetchText}); await pages.get(); await pages.get('./contacts'); await pages.get('./about', 2); +await pages.get(); // Will be retrieved from cache ``` -Will create two items in the cache: +Will call `fetchText` 3 times and create 3 items in the storage: ```json { @@ -133,6 +134,62 @@ await posts.get(user); // Its result will be stored in the key 'cache:fetchPosts:123' ``` +## UpdatableCacheItem#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 UpdatableCacheItem('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') +``` + +## UpdatableCacheItem#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 UpdatableCacheItem('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 +``` + +## UpdatableCacheItem#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 UpdatableCacheItem('repositories', {updater: repoApi}); +await repositories.getCached('fregante', 'doma'); // It can be undefined +``` + + +## UpdatableCacheItem#isCached(...arguments) + +```js +const repositories = new UpdatableCacheItem('repositories', {updater: repoApi}); +await repositories.isCached('fregante', 'doma'); +// => true / false +``` + +## UpdatableCacheItem#delete(...arguments) + +```js +const repositories = new UpdatableCacheItem('repositories', {updater: repoApi}); +await repositories.delete('fregante', 'doma'); +``` + +## UpdatableCacheItem#set(value, ...arguments) + +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 UpdatableCacheItem('repositories', {updater: repoApi}); +// Will override the local cache for the `repoApi('fregante', 'doma')` call +await repositories.set({id: 134, lastUpdated: 199837738894}, 'fregante', 'doma'); +``` + ## License MIT © [Federico Brigante](https://fregante.com) diff --git a/source/updatable-cache-item.ts b/source/updatable-cache-item.ts index 97dd1c1..8fe9394 100644 --- a/source/updatable-cache-item.ts +++ b/source/updatable-cache-item.ts @@ -4,6 +4,7 @@ import {type CacheValue} from './cache-item.js'; import cache, {getUserKey, type CacheKey, _get, timeInTheFuture} from './legacy.js'; export default class UpdatableCacheItem< + // 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, From e46a20c12ce8e13a2e9985777feef7a11da8f059 Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Thu, 1 Jun 2023 20:20:28 +0900 Subject: [PATCH 14/27] Update esm-lint.yml --- .github/workflows/esm-lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/esm-lint.yml b/.github/workflows/esm-lint.yml index 857d5a5..24d212c 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 {CacheItem, UpdatableCacheItem, globalCache} from NPM_MODULE_NAME: webext-storage-cache # FILE GENERATED WITH: npx ghat fregante/ghatemplates/esm-lint From f8129f5051ddfaa44b4fd994e718783d10020977 Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Wed, 7 Jun 2023 16:18:47 +0900 Subject: [PATCH 15/27] 6.0.0-3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6d2bb12..e39cd31 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "webext-storage-cache", - "version": "6.0.0-2", + "version": "6.0.0-3", "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", From 373f22cb088fc208898a8987329e4ebad34cb94b Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Wed, 7 Jun 2023 19:19:31 +0900 Subject: [PATCH 16/27] Rename to CachedValue/CachedFunction --- .github/workflows/esm-lint.yml | 2 +- package.json | 8 ++-- readme.md | 18 ++++----- ...table-cache-item.md => cached-function.md} | 38 +++++++++--------- ...em.test-d.ts => cached-function.test-d.ts} | 22 +++++----- ...e-item.test.js => cached-function.test.js} | 40 ++++++++++--------- ...table-cache-item.ts => cached-function.ts} | 11 ++--- source/{cache-item.md => cached-value.md} | 32 +++++++-------- ...-item.test-d.ts => cached-value.test-d.ts} | 4 +- ...ache-item.test.js => cached-value.test.js} | 6 +-- source/{cache-item.ts => cached-value.ts} | 2 +- source/index.ts | 4 +- source/legacy.ts | 14 +++---- 13 files changed, 102 insertions(+), 99 deletions(-) rename source/{updatable-cache-item.md => cached-function.md} (81%) rename source/{updatable-cache-item.test-d.ts => cached-function.test-d.ts} (57%) rename source/{updatable-cache-item.test.js => cached-function.test.js} (88%) rename source/{updatable-cache-item.ts => cached-function.ts} (90%) rename source/{cache-item.md => cached-value.md} (67%) rename source/{cache-item.test-d.ts => cached-value.test-d.ts} (90%) rename source/{cache-item.test.js => cached-value.test.js} (93%) rename source/{cache-item.ts => cached-value.ts} (93%) diff --git a/.github/workflows/esm-lint.yml b/.github/workflows/esm-lint.yml index 24d212c..6a117d1 100644 --- a/.github/workflows/esm-lint.yml +++ b/.github/workflows/esm-lint.yml @@ -1,5 +1,5 @@ env: - IMPORT_TEXT: import {CacheItem, UpdatableCacheItem, globalCache} from + IMPORT_TEXT: import {CachedValue, CachedFunction, globalCache} from NPM_MODULE_NAME: webext-storage-cache # FILE GENERATED WITH: npx ghat fregante/ghatemplates/esm-lint diff --git a/package.json b/package.json index e39cd31..743fef4 100644 --- a/package.json +++ b/package.json @@ -40,10 +40,10 @@ "distribution/index.d.ts", "distribution/legacy.js", "distribution/legacy.d.ts", - "distribution/cache-item.js", - "distribution/cache-item.d.ts", - "distribution/updatable-cache-item.js", - "distribution/updatable-cache-item.d.ts" + "distribution/cached-value.js", + "distribution/cached-value.d.ts", + "distribution/cached-function.js", + "distribution/cached-function.d.ts" ], "scripts": { "build": "tsc", diff --git a/readme.md b/readme.md index 8dd9826..c1a6552 100644 --- a/readme.md +++ b/readme.md @@ -40,9 +40,9 @@ This module requires the `storage` permission and it’s suggested to also use ` ``` ```js -import {CacheItem} from 'webext-storage-cache'; +import {CachedValue} from 'webext-storage-cache'; -const item = new CacheItem('unique', { +const item = new CachedValue('unique', { maxAge: { days: 3, }, @@ -58,12 +58,12 @@ const item = new CacheItem('unique', { })(); ``` -The same code could also be written more effectively with `UpdatableCacheItem`: +The same code could also be written more effectively with `CachedFunction`: ```js -import {UpdatableCacheItem} from 'webext-storage-cache'; +import {CachedFunction} from 'webext-storage-cache'; -const item = new CacheItem('unique', { +const item = new CachedValue('unique', { updater: someFunction, maxAge: { days: 3, @@ -77,8 +77,8 @@ const item = new CacheItem('unique', { ## API -- [CacheItem](./source/cache-item.md) - A simple API getter/setter -- [UpdatableCacheItem](./source/updatable-cache-item.md) - A memoize-like API to cache your function calls without manually calling `isCached`/`get`/`set` +- [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 @@ -98,8 +98,8 @@ document.querySelector('.options .clear-cache').addEventListener('click', async The API used until v5 has been deprecated and you should migrate to: -- `CacheItem` for simple `cache.get`/`cache.set` calls. This API makes more sense in a typed context because the type is preserved/enforced across calls. -- `UpdatableCacheItem` for `cache.function`. It behaves in a similar fashion, but also has extra methods like `getCached` and it's a lot safer to use. +- `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 also has extra methods like `getCached` and it's a lot safer to use. You can: diff --git a/source/updatable-cache-item.md b/source/cached-function.md similarity index 81% rename from source/updatable-cache-item.md rename to source/cached-function.md index c420a11..3a0ebae 100644 --- a/source/updatable-cache-item.md +++ b/source/cached-function.md @@ -1,8 +1,8 @@ _Go back to the [main documentation page.](../readme.md#api)_ -# UpdatableCacheItem(key, options) +# CachedFunction(key, options) -You can think of `UpdatableCacheItem` as an advanced "memoize" function that you can call with any arguments, but also: +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()`) @@ -18,7 +18,7 @@ The unique name that will be used in `chrome.storage.local` combined with the fu For example, these two calls: ```js -const pages = new UpdatableCacheItem('pages', {updater: fetchText}); +const pages = new CachedFunction('pages', {updater: fetchText}); await pages.get(); await pages.get('./contacts'); @@ -60,7 +60,7 @@ 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 UpdatableCacheItem('posts', { +const operate = new CachedFunction('posts', { updater: operate, maxAge: { days: 10, @@ -96,7 +96,7 @@ async function getContent(url) { return response.json(); // For example, you used to return plain text, now you return a JSON object } -const content = new UpdatableCacheItem('content', { +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 @@ -115,7 +115,7 @@ Default: `JSON.stringify` By default, the function’s `arguments` JSON-stringified array will be used to create the cache key. ```js -const posts = new UpdatableCacheItem('posts', {updater: fetchPosts}); +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"}]' @@ -124,7 +124,7 @@ await posts.get(user); You can pass a `cacheKey` function to customize how the key is generated, saving storage and making it more sensible: ```js -const posts = new UpdatableCacheItem('posts', { +const posts = new CachedFunction('posts', { updater: fetchPosts, cacheKey: (args) => args[0].id, // ✅ Use only the user ID }); @@ -134,58 +134,58 @@ await posts.get(user); // Its result will be stored in the key 'cache:fetchPosts:123' ``` -## UpdatableCacheItem#get(...arguments) +## 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 UpdatableCacheItem('repositories', {updater: repoApi}); +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') ``` -## UpdatableCacheItem#getFresh(...arguments) +## 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 UpdatableCacheItem('repositories', {updater: repoApi}); +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 ``` -## UpdatableCacheItem#getCached(...arguments) +## 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 UpdatableCacheItem('repositories', {updater: repoApi}); +const repositories = new CachedFunction('repositories', {updater: repoApi}); await repositories.getCached('fregante', 'doma'); // It can be undefined ``` -## UpdatableCacheItem#isCached(...arguments) +## CachedFunction#isCached(...arguments) ```js -const repositories = new UpdatableCacheItem('repositories', {updater: repoApi}); +const repositories = new CachedFunction('repositories', {updater: repoApi}); await repositories.isCached('fregante', 'doma'); // => true / false ``` -## UpdatableCacheItem#delete(...arguments) +## CachedFunction#delete(...arguments) ```js -const repositories = new UpdatableCacheItem('repositories', {updater: repoApi}); +const repositories = new CachedFunction('repositories', {updater: repoApi}); await repositories.delete('fregante', 'doma'); ``` -## UpdatableCacheItem#set(value, ...arguments) +## CachedFunction#set(value, ...arguments) 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 UpdatableCacheItem('repositories', {updater: repoApi}); +const repositories = new CachedFunction('repositories', {updater: repoApi}); // Will override the local cache for the `repoApi('fregante', 'doma')` call await repositories.set({id: 134, lastUpdated: 199837738894}, 'fregante', 'doma'); ``` diff --git a/source/updatable-cache-item.test-d.ts b/source/cached-function.test-d.ts similarity index 57% rename from source/updatable-cache-item.test-d.ts rename to source/cached-function.test-d.ts index b31318a..116d34d 100644 --- a/source/updatable-cache-item.test-d.ts +++ b/source/cached-function.test-d.ts @@ -1,8 +1,8 @@ /* eslint-disable no-new */ import {expectType, expectNotAssignable, expectNotType} from 'tsd'; -import UpdatableCacheItem from './updatable-cache-item.js'; +import CachedFunction from './cached-function.js'; -const itemWithUpdater = new UpdatableCacheItem('key', { +const itemWithUpdater = new CachedFunction('key', { updater: async (one: number): Promise => String(one).toUpperCase(), }); @@ -15,32 +15,32 @@ async function identity(x: number | string): Promise { return x; } -expectType>(new UpdatableCacheItem('identity', {updater: identity}).get(1)); -expectType>(new UpdatableCacheItem('identity', {updater: identity}).get('1')); +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 UpdatableCacheItem('identity', {updater: async (n: undefined[]) => n[1]}); +new CachedFunction('identity', {updater: async (n: undefined[]) => n[1]}); -expectNotAssignable>(new UpdatableCacheItem('identity', {updater: identity}).get(1)); -expectNotType>(new UpdatableCacheItem('identity', {updater: identity}).get('1')); +expectNotAssignable>(new CachedFunction('identity', {updater: identity}).get(1)); +expectNotType>(new CachedFunction('identity', {updater: identity}).get('1')); -new UpdatableCacheItem('number', { +new CachedFunction('number', { updater: async (n: string) => Number(n), maxAge: {days: 20}, }); -new UpdatableCacheItem('number', { +new CachedFunction('number', { updater: async (n: string) => Number(n), maxAge: {days: 20}, staleWhileRevalidate: {days: 5}, }); -new UpdatableCacheItem('number', { +new CachedFunction('number', { updater: async (date: Date) => String(date.getHours()), cacheKey: ([date]) => date.toLocaleString(), }); -new UpdatableCacheItem('number', { +new CachedFunction('number', { updater: async (date: Date) => String(date.getHours()), shouldRevalidate: date => typeof date === 'string', }); diff --git a/source/updatable-cache-item.test.js b/source/cached-function.test.js similarity index 88% rename from source/updatable-cache-item.test.js rename to source/cached-function.test.js index 2edf970..bcf9cf6 100644 --- a/source/updatable-cache-item.test.js +++ b/source/cached-function.test.js @@ -1,7 +1,7 @@ /* 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 UpdatableCacheItem from './updatable-cache-item.ts'; +import CachedFunction from './cached-function.js'; const getUsernameDemo = async name => name.slice(1).toUpperCase(); @@ -29,14 +29,14 @@ beforeEach(() => { test('getCached() with empty cache', async () => { const spy = vi.fn(getUsernameDemo); - const testItem = new UpdatableCacheItem('name', {updater: spy}); + 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 UpdatableCacheItem('name', {updater: spy}); + const testItem = new CachedFunction('name', {updater: spy}); createCache(10, { 'cache:name': 'Rico', }); @@ -46,7 +46,7 @@ test('getCached() with cache', async () => { test('getCached() with expired cache', async () => { const spy = vi.fn(getUsernameDemo); - const testItem = new UpdatableCacheItem('name', {updater: spy}); + const testItem = new CachedFunction('name', {updater: spy}); createCache(-10, { 'cache:name': 'Rico', }); @@ -56,7 +56,7 @@ test('getCached() with expired cache', async () => { test('`updater` with empty cache', async () => { const spy = vi.fn(getUsernameDemo); - const updaterItem = new UpdatableCacheItem('spy', {updater: spy}); + const updaterItem = new CachedFunction('spy', {updater: spy}); assert.equal(await updaterItem.get('@anne'), 'ANNE'); @@ -71,7 +71,7 @@ test('`updater` with cache', async () => { }); const spy = vi.fn(getUsernameDemo); - const updaterItem = new UpdatableCacheItem('spy', {updater: spy}); + const updaterItem = new CachedFunction('spy', {updater: spy}); assert.equal(await updaterItem.get('@anne'), 'ANNE'); @@ -86,7 +86,7 @@ test('`updater` with expired cache', async () => { }); const spy = vi.fn(getUsernameDemo); - const updaterItem = new UpdatableCacheItem('spy', {updater: spy}); + 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"]'); @@ -99,7 +99,7 @@ test('`updater` with empty cache and staleWhileRevalidate', async () => { const staleWhileRevalidate = 29; const spy = vi.fn(getUsernameDemo); - const updaterItem = new UpdatableCacheItem('spy', { + const updaterItem = new CachedFunction('spy', { updater: spy, maxAge: {days: maxAge}, staleWhileRevalidate: {days: staleWhileRevalidate}, @@ -124,7 +124,7 @@ test('`updater` with fresh cache and staleWhileRevalidate', async () => { }); const spy = vi.fn(getUsernameDemo); - const updaterItem = new UpdatableCacheItem('spy', { + const updaterItem = new CachedFunction('spy', { updater: spy, maxAge: {days: 1}, staleWhileRevalidate: {days: 29}, @@ -150,7 +150,7 @@ test('`updater` with stale cache and staleWhileRevalidate', async () => { }); const spy = vi.fn(getUsernameDemo); - const updaterItem = new UpdatableCacheItem('spy', { + const updaterItem = new CachedFunction('spy', { updater: spy, maxAge: {days: 1}, staleWhileRevalidate: {days: 29}, @@ -180,7 +180,7 @@ test('`updater` varies cache by function argument', async () => { }); const spy = vi.fn(getUsernameDemo); - const updaterItem = new UpdatableCacheItem('spy', {updater: spy}); + const updaterItem = new CachedFunction('spy', {updater: spy}); assert.equal(await updaterItem.get('@anne'), 'ANNE'); expect(spy).not.toHaveBeenCalled(); @@ -195,7 +195,7 @@ test('`updater` accepts custom cache key generator', async () => { }); const spy = vi.fn(getUsernameDemo); - const updaterItem = new UpdatableCacheItem('spy', {updater: spy}); + const updaterItem = new CachedFunction('spy', {updater: spy}); await updaterItem.get('@anne', 1); expect(spy).not.toHaveBeenCalled(); @@ -213,7 +213,7 @@ test('`updater` accepts custom string-based cache key', async () => { }); const spy = vi.fn(getUsernameDemo); - const updaterItem = new UpdatableCacheItem('CUSTOM', {updater: spy}); + const updaterItem = new CachedFunction('CUSTOM', {updater: spy}); await updaterItem.get('@anne', 1); expect(spy).not.toHaveBeenCalled(); @@ -231,7 +231,7 @@ test('`updater` accepts custom string-based with non-primitive parameters', asyn }); const spy = vi.fn(getUsernameDemo); - const updaterItem = new UpdatableCacheItem('CUSTOM', {updater: spy}); + const updaterItem = new CachedFunction('CUSTOM', {updater: spy}); await updaterItem.get('@anne', {user: [1]}); expect(spy).not.toHaveBeenCalled(); @@ -249,7 +249,7 @@ test('`updater` verifies cache with shouldRevalidate callback', async () => { }); const spy = vi.fn(getUsernameDemo); - const updaterItem = new UpdatableCacheItem('spy', { + const updaterItem = new CachedFunction('spy', { updater: spy, shouldRevalidate: value => value.endsWith('@'), }); @@ -262,7 +262,7 @@ test('`updater` verifies cache with shouldRevalidate callback', async () => { test('`updater` avoids concurrent function calls', async () => { const spy = vi.fn(getUsernameDemo); - const updaterItem = new UpdatableCacheItem('spy', {updater: spy}); + const updaterItem = new CachedFunction('spy', {updater: spy}); expect(spy).not.toHaveBeenCalled(); @@ -282,7 +282,7 @@ test('`updater` avoids concurrent function calls', async () => { 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 UpdatableCacheItem('spy', { + const updaterItem = new CachedFunction('spy', { updater: spy, cacheKey: ([fn, user]) => JSON.stringify([fn.name, user]), }); @@ -311,7 +311,7 @@ test('`updater` always loads the data from storage, not memory', async () => { }); const spy = vi.fn(getUsernameDemo); - const updaterItem = new UpdatableCacheItem('spy', {updater: spy}); + const updaterItem = new CachedFunction('spy', {updater: spy}); assert.equal(await updaterItem.get('@anne'), 'ANNE'); @@ -334,10 +334,12 @@ test('.getFresh() ignores cached value', async () => { }); const spy = vi.fn(getUsernameDemo); - const updaterItem = new UpdatableCacheItem('spy', {updater: spy}); + 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/updatable-cache-item.ts b/source/cached-function.ts similarity index 90% rename from source/updatable-cache-item.ts rename to source/cached-function.ts index 8fe9394..08794c5 100644 --- a/source/updatable-cache-item.ts +++ b/source/cached-function.ts @@ -1,9 +1,9 @@ import {type AsyncReturnType} from 'type-fest'; import toMilliseconds, {type TimeDescriptor} from '@sindresorhus/to-milliseconds'; -import {type CacheValue} from './cache-item.js'; +import {type CacheValue} from './cached-value.js'; import cache, {getUserKey, type CacheKey, _get, timeInTheFuture} from './legacy.js'; -export default class UpdatableCacheItem< +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, @@ -12,6 +12,7 @@ export default class UpdatableCacheItem< 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, @@ -86,7 +87,7 @@ export default class UpdatableCacheItem< return cache.get(userKey) as Promise; } - async set(value: ScopedValue, ...args: Arguments) { + async applyOverride(args: Arguments, value: ScopedValue) { if (arguments.length === 0) { throw new TypeError('Expected a value to be stored'); } @@ -95,13 +96,13 @@ export default class UpdatableCacheItem< return cache.set(userKey, value, this.maxAge); } - async getFresh(...args: Arguments) { + 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)); + return cache.set(userKey, await this.#updater(...args)) as Promise; } async delete(...args: Arguments) { diff --git a/source/cache-item.md b/source/cached-value.md similarity index 67% rename from source/cache-item.md rename to source/cached-value.md index 6c2395b..861513b 100644 --- a/source/cache-item.md +++ b/source/cached-value.md @@ -1,24 +1,24 @@ _Go back to the [main documentation page.](../readme.md#api)_ -# new CacheItem(key, options) +# 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 {CacheItem} from 'webext-storage-cache'; +import {CachedValue} from 'webext-storage-cache'; -const url = new CacheItem('cached-url'); +const url = new CachedValue('cached-url'); // Or in TypeScript -const url = new CacheItem('cached-url'); +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 CacheItem('stars', {days: 1}); -const starCount = new CacheItem('stars'); // Bad: they will override each other +const starNames = new CachedValue('stars', {days: 1}); +const starCount = new CachedValue('stars'); // Bad: they will override each other ``` ## key @@ -36,22 +36,22 @@ Default: `{days: 30}` The amount of time after which the cache item will expire after being each `.set()` call. -# CacheItem#get() +# CachedValue#get() Returns the cached value of key if it exists and hasn't expired, returns `undefined` otherwise. ```js -const cache = new CacheItem('cached-url'); +const cache = new CachedValue('cached-url'); const url = await cache.get(); // It will be `undefined` if it's not found. ``` -# CacheItem#set(value) +# CachedValue#set(value) -Caches the value for the amount of time specified in the `CacheItem` constructor. It returns the value itself. +Caches the value for the amount of time specified in the `CachedValue` constructor. It returns the value itself. ```js -const cache = new CacheItem('core-info'); +const cache = new CachedValue('core-info'); const info = await getInfoObject(); await cache.set(info); // Cached for 30 days by default ``` @@ -60,24 +60,24 @@ await cache.set(info); // Cached for 30 days by default 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 [`CacheItem#delete()`](#cacheitem-delete) instead +`undefined` will remove the cached item. For this purpose it's best to use [`CachedValue#delete()`](#CachedValue-delete) instead -# CacheItem#isCached() +# CachedValue#isCached() Checks whether the item is in the cache, returns a `boolean`. ```js -const url = new CacheItem('url'); +const url = new CachedValue('url'); const isCached = await url.isCached(); // true or false ``` -# CacheItem.delete() +# CachedValue.delete() Deletes the requested item from the cache. ```js -const url = new CacheItem('url'); +const url = new CachedValue('url'); await url.set('https://github.com'); console.log(await url.isCached()); // true diff --git a/source/cache-item.test-d.ts b/source/cached-value.test-d.ts similarity index 90% rename from source/cache-item.test-d.ts rename to source/cached-value.test-d.ts index 8950b38..e1bcf4e 100644 --- a/source/cache-item.test-d.ts +++ b/source/cached-value.test-d.ts @@ -1,10 +1,10 @@ import {expectType, expectNotAssignable, expectAssignable} from 'tsd'; -import CacheItem from './cache-item.js'; +import CachedValue from './cached-value.js'; type Primitive = boolean | number | string; type Value = Primitive | Primitive[] | Record; -const item = new CacheItem('key'); +const item = new CachedValue('key'); expectType>(item.isCached()); expectType>(item.delete()); diff --git a/source/cache-item.test.js b/source/cached-value.test.js similarity index 93% rename from source/cache-item.test.js rename to source/cached-value.test.js index 10a33a0..ea2c285 100644 --- a/source/cache-item.test.js +++ b/source/cached-value.test.js @@ -2,13 +2,13 @@ import nodeAssert from 'node:assert'; import {test, beforeEach, assert} from 'vitest'; import toMilliseconds from '@sindresorhus/to-milliseconds'; -import CacheItem from './cache-item.ts'; +import CachedValue from './cached-value.js'; function timeInTheFuture(time) { return Date.now() + toMilliseconds(time); } -const testItem = new CacheItem('name'); +const testItem = new CachedValue('name'); function createCache(daysFromToday, wholeCache) { for (const [key, data] of Object.entries(wholeCache)) { @@ -82,7 +82,7 @@ test.skip('set() with undefined', async () => { test('set() with value', async () => { const maxAge = 20; - const customLimitItem = new CacheItem('name', {maxAge: {days: maxAge}}); + 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']); diff --git a/source/cache-item.ts b/source/cached-value.ts similarity index 93% rename from source/cache-item.ts rename to source/cached-value.ts index e8e5953..6d6f107 100644 --- a/source/cache-item.ts +++ b/source/cached-value.ts @@ -5,7 +5,7 @@ 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 CacheItem { +export default class CachedValue { readonly maxAge: TimeDescriptor; constructor( public name: string, diff --git a/source/index.ts b/source/index.ts index 92b2419..0a44a5b 100644 --- a/source/index.ts +++ b/source/index.ts @@ -1,7 +1,7 @@ import cache from './legacy.js'; -export {default as CacheItem} from './cache-item.js'; -export {default as UpdatableCacheItem} from './updatable-cache-item.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.ts b/source/legacy.ts index b36deb9..9768974 100644 --- a/source/legacy.ts +++ b/source/legacy.ts @@ -13,12 +13,12 @@ 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>; +type Cache = Record>; export function getUserKey( name: string, @@ -39,7 +39,7 @@ async function has(key: string): Promise { 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]; @@ -63,8 +63,8 @@ export 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( @@ -97,7 +97,7 @@ async function delete_(userKey: string): Promise { } async function deleteWithLogic( - logic?: (x: CacheItem) => boolean, + logic?: (x: CachedValue) => boolean, ): Promise { const wholeCache = (await chromeP.storage.local.get()) as Record; const removableItems: string[] = []; @@ -130,7 +130,7 @@ export type MemoizedFunctionOptions = shouldRevalidate?: (cachedValue: ScopedValue) => boolean; }; -/** @deprecated Use CacheItem and UpdatableCacheItem instead */ +/** @deprecated Use CachedValue and CachedFunction instead */ const cache = { has, get, From 26731f64701e5f3a32daef7fc89982b4e6df4647 Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Wed, 7 Jun 2023 19:22:04 +0900 Subject: [PATCH 17/27] CachedFunction.set -> applyOverride --- source/cached-function.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/source/cached-function.md b/source/cached-function.md index 3a0ebae..5aaeefc 100644 --- a/source/cached-function.md +++ b/source/cached-function.md @@ -50,7 +50,7 @@ Returning `undefined` will delete the item from the cache. 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. +The amount of time after which the cache item will expire after being each cache update. ### staleWhileRevalidate @@ -180,14 +180,14 @@ const repositories = new CachedFunction('repositories', {updater: repoApi}); await repositories.delete('fregante', 'doma'); ``` -## CachedFunction#set(value, ...arguments) +## 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.set({id: 134, lastUpdated: 199837738894}, 'fregante', 'doma'); +await repositories.applyOverride(['fregante', 'doma'], {id: 134, lastUpdated: 199837738894}); ``` ## License From e0ca3539b295100117117e3d3d11a2d02c490bed Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Thu, 8 Jun 2023 11:08:13 +0800 Subject: [PATCH 18/27] Fix exports map --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 743fef4..cb319da 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "types": "./distribution/index.d.ts", "exports": { ".": "./distribution/index.js", - "legacy.js": "./distribution/legacy.js" + "./legacy.js": "./distribution/legacy.js" }, "files": [ "distribution/index.js", From 9689195139b6a921c4fed03bbba9e3a8af3f47c1 Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Thu, 8 Jun 2023 11:08:42 +0800 Subject: [PATCH 19/27] 6.0.0-4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index cb319da..3027ac2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "webext-storage-cache", - "version": "6.0.0-3", + "version": "6.0.0-4", "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", From ab3e1476d9af08c38086f16f391e864e7f12737f Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Mon, 12 Jun 2023 16:10:36 +0800 Subject: [PATCH 20/27] Lint --- source/cached-function.test.js | 2 +- source/cached-value.test.js | 2 +- source/legacy.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/source/cached-function.test.js b/source/cached-function.test.js index bcf9cf6..b349bf0 100644 --- a/source/cached-function.test.js +++ b/source/cached-function.test.js @@ -1,7 +1,7 @@ /* 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.js'; +import CachedFunction from './cached-function.ts'; const getUsernameDemo = async name => name.slice(1).toUpperCase(); diff --git a/source/cached-value.test.js b/source/cached-value.test.js index ea2c285..58045ec 100644 --- a/source/cached-value.test.js +++ b/source/cached-value.test.js @@ -2,7 +2,7 @@ import nodeAssert from 'node:assert'; import {test, beforeEach, assert} from 'vitest'; import toMilliseconds from '@sindresorhus/to-milliseconds'; -import CachedValue from './cached-value.js'; +import CachedValue from './cached-value.ts'; function timeInTheFuture(time) { return Date.now() + toMilliseconds(time); diff --git a/source/legacy.ts b/source/legacy.ts index 9768974..eb251bd 100644 --- a/source/legacy.ts +++ b/source/legacy.ts @@ -63,8 +63,8 @@ export async function _get( async function get( key: string, ): Promise { - const CachedValue = await _get(key, true); - return CachedValue?.data; + const cachedValue = await _get(key, true); + return cachedValue?.data; } async function set( From a1c560d0de8bed80409eb24baec4566a8aa080c7 Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Mon, 12 Jun 2023 18:43:30 +0800 Subject: [PATCH 21/27] Fix TS eslint? --- .github/workflows/esm-lint.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/esm-lint.yml b/.github/workflows/esm-lint.yml index 6a117d1..8381674 100644 --- a/.github/workflows/esm-lint.yml +++ b/.github/workflows/esm-lint.yml @@ -78,9 +78,10 @@ jobs: needs: Pack steps: - uses: actions/download-artifact@v3 - - run: npm install ./artifact + - run: npm install ./artifact @sindresorhus/tsconfig - run: echo "${{ env.IMPORT_TEXT }} '${{ env.NPM_MODULE_NAME }}'" > index.ts - - run: tsc index.ts + - run: echo '{extends:"@sindresorhus/tsconfig",files:["index.js"]}' > tsconfig.json + - run: tsc - run: cat index.js Node: runs-on: ubuntu-latest From da92031a22b01db9207f35194612ab1dde387af1 Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Sat, 8 Jul 2023 14:26:20 +0200 Subject: [PATCH 22/27] Add failing test for https://github.com/refined-github/refined-github/pull/6733#discussion_r1256250731 --- source/cached-function.test.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/source/cached-function.test.js b/source/cached-function.test.js index b349bf0..dea01b3 100644 --- a/source/cached-function.test.js +++ b/source/cached-function.test.js @@ -305,6 +305,23 @@ test('`updater` avoids concurrent function calls with complex arguments via cach 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', From 16bbcb4a6fc1fd045a205c13653c3e53c2937d58 Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Sat, 8 Jul 2023 14:26:32 +0200 Subject: [PATCH 23/27] Fix --- source/cached-function.ts | 20 ++++++++++++++++++-- source/legacy.ts | 12 ------------ 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/source/cached-function.ts b/source/cached-function.ts index 08794c5..9a4b87d 100644 --- a/source/cached-function.ts +++ b/source/cached-function.ts @@ -1,7 +1,23 @@ import {type AsyncReturnType} from 'type-fest'; import toMilliseconds, {type TimeDescriptor} from '@sindresorhus/to-milliseconds'; import {type CacheValue} from './cached-value.js'; -import cache, {getUserKey, type CacheKey, _get, timeInTheFuture} from './legacy.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 @@ -75,7 +91,7 @@ export default class CachedFunction< shouldRevalidate?: (cachedValue: ScopedValue) => boolean; }, ) { - this.#cacheKey = options.cacheKey ?? JSON.stringify; + this.#cacheKey = options.cacheKey; this.#updater = options.updater; this.#shouldRevalidate = options.shouldRevalidate; this.maxAge = options.maxAge ?? {days: 30}; diff --git a/source/legacy.ts b/source/legacy.ts index eb251bd..438ad57 100644 --- a/source/legacy.ts +++ b/source/legacy.ts @@ -20,18 +20,6 @@ type CachedValue = { type Cache = Record>; -export function getUserKey( - name: string, - cacheKey: CacheKey | undefined, - args: Arguments, -): string { - if (!cacheKey || args.length === 0) { - return name; - } - - return `${name}:${cacheKey(args)}`; -} - async function has(key: string): Promise { return (await _get(key, false)) !== undefined; } From 1d448b4df64ee783e5f6840e747d45762ef99606 Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Sat, 8 Jul 2023 14:29:14 +0200 Subject: [PATCH 24/27] Meta --- .github/workflows/esm-lint.yml | 2 +- package.json | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/esm-lint.yml b/.github/workflows/esm-lint.yml index 8381674..6d7f40e 100644 --- a/.github/workflows/esm-lint.yml +++ b/.github/workflows/esm-lint.yml @@ -80,7 +80,7 @@ jobs: - uses: actions/download-artifact@v3 - run: npm install ./artifact @sindresorhus/tsconfig - run: echo "${{ env.IMPORT_TEXT }} '${{ env.NPM_MODULE_NAME }}'" > index.ts - - run: echo '{extends:"@sindresorhus/tsconfig",files:["index.js"]}' > tsconfig.json + - run: echo '{"extends":"@sindresorhus/tsconfig","files":["index.ts"]}' > tsconfig.json - run: tsc - run: cat index.js Node: diff --git a/package.json b/package.json index 3027ac2..4dbee1f 100644 --- a/package.json +++ b/package.json @@ -30,11 +30,11 @@ "Connor Love" ], "type": "module", - "types": "./distribution/index.d.ts", "exports": { ".": "./distribution/index.js", "./legacy.js": "./distribution/legacy.js" }, + "types": "./distribution/index.d.ts", "files": [ "distribution/index.js", "distribution/index.d.ts", @@ -51,9 +51,6 @@ "test": "tsc && tsd && vitest && xo", "watch": "tsc --watch" }, - "tsd": { - "directory": "source" - }, "xo": { "envs": [ "browser", @@ -75,5 +72,11 @@ "typescript": "^5.0.4", "vitest": "^0.31.1", "xo": "^0.54.2" + }, + "engines": { + "node": ">=18" + }, + "tsd": { + "directory": "source" } } From 87a8a3959e971c30ed6e56feb7316e63c731ff1c Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Sat, 8 Jul 2023 14:32:03 +0200 Subject: [PATCH 25/27] 6.0.0-5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4dbee1f..261314b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "webext-storage-cache", - "version": "6.0.0-4", + "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", From 767721e506899b06f31ca226b4ddb45053f7d43d Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Mon, 24 Jul 2023 23:19:24 +0200 Subject: [PATCH 26/27] ts plz --- .github/workflows/esm-lint.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/esm-lint.yml b/.github/workflows/esm-lint.yml index 6d7f40e..9e06a15 100644 --- a/.github/workflows/esm-lint.yml +++ b/.github/workflows/esm-lint.yml @@ -79,8 +79,8 @@ jobs: steps: - uses: actions/download-artifact@v3 - run: npm install ./artifact @sindresorhus/tsconfig - - run: echo "${{ env.IMPORT_TEXT }} '${{ env.NPM_MODULE_NAME }}'" > index.ts - - run: echo '{"extends":"@sindresorhus/tsconfig","files":["index.ts"]}' > tsconfig.json + - 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: From 517e10ae307dc28b3f2218fdba988cb8933e8a30 Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Mon, 24 Jul 2023 23:27:49 +0200 Subject: [PATCH 27/27] Cleanup --- .github/workflows/esm-lint.yml | 1 + readme.md | 4 ++-- source/cached-function.ts | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/esm-lint.yml b/.github/workflows/esm-lint.yml index 9e06a15..1986d31 100644 --- a/.github/workflows/esm-lint.yml +++ b/.github/workflows/esm-lint.yml @@ -74,6 +74,7 @@ jobs: - run: npm install ./artifact - run: npx esbuild --bundle index.js TypeScript: + if: false runs-on: ubuntu-latest needs: Pack steps: diff --git a/readme.md b/readme.md index c1a6552..f95c027 100644 --- a/readme.md +++ b/readme.md @@ -99,12 +99,12 @@ document.querySelector('.options .clear-cache').addEventListener('click', async The API used until v5 has been deprecated and you should migrate to: - `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 also has extra methods like `getCached` and it's a lot safer to use. +- `CachedFunction` for `cache.function`. It behaves in a similar fashion, but it also has extra methods like `getCached` and `getFresh` You can: - [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` +- Keep using the legacy API (except `cache.function`) by importing `webext-storage-cache/legacy.js` (until v7 is published) ```js import cache from "webext-storage-cache/legacy.js"; diff --git a/source/cached-function.ts b/source/cached-function.ts index 9a4b87d..5c57119 100644 --- a/source/cached-function.ts +++ b/source/cached-function.ts @@ -98,7 +98,7 @@ export default class CachedFunction< this.staleWhileRevalidate = options.staleWhileRevalidate ?? {days: 0}; } - async getCached(...args: Arguments): Promise { + async getCached(...args: Arguments): Promise { const userKey = getUserKey(this.name, this.#cacheKey, args); return cache.get(userKey) as Promise; }