diff --git a/package-lock.json b/package-lock.json index 73e896c..ea8a001 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "optimism", - "version": "0.17.5", + "version": "0.18.0-pre.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "optimism", - "version": "0.17.5", + "version": "0.18.0-pre.0", "license": "MIT", "dependencies": { + "@wry/caches": "^1.0.0", "@wry/context": "^0.7.0", "@wry/trie": "^0.4.3", "tslib": "^2.3.0" @@ -35,6 +36,17 @@ "integrity": "sha512-JJulVEQXmiY9Px5axXHeYGLSjhkZEnD+MDPDGbCbIAbMslkKwmygtZFy1X6s/075Yo94sf8GuSlFfPzysQrWZQ==", "dev": true }, + "node_modules/@wry/caches": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@wry/caches/-/caches-1.0.0.tgz", + "integrity": "sha512-FHRUDe2tqrXAj6A/1D39No68lFWbbnh+NCpG9J/6idhL/2Mb/AaxBTYg/sbUVImEo8a4mWeOewUlB1W7uLjByA==", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@wry/context": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/@wry/context/-/context-0.7.3.tgz", @@ -1194,6 +1206,14 @@ "integrity": "sha512-JJulVEQXmiY9Px5axXHeYGLSjhkZEnD+MDPDGbCbIAbMslkKwmygtZFy1X6s/075Yo94sf8GuSlFfPzysQrWZQ==", "dev": true }, + "@wry/caches": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@wry/caches/-/caches-1.0.0.tgz", + "integrity": "sha512-FHRUDe2tqrXAj6A/1D39No68lFWbbnh+NCpG9J/6idhL/2Mb/AaxBTYg/sbUVImEo8a4mWeOewUlB1W7uLjByA==", + "requires": { + "tslib": "^2.3.0" + } + }, "@wry/context": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/@wry/context/-/context-0.7.3.tgz", diff --git a/package.json b/package.json index 9a3a2e7..5f136e2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "optimism", - "version": "0.17.5", + "version": "0.18.0-pre.0", "author": "Ben Newman <ben@benjamn.com>", "description": "Composable reactive caching with efficient invalidation.", "keywords": [ @@ -47,6 +47,7 @@ "typescript": "^5.0.2" }, "dependencies": { + "@wry/caches": "^1.0.0", "@wry/context": "^0.7.0", "@wry/trie": "^0.4.3", "tslib": "^2.3.0" diff --git a/src/cache.ts b/src/cache.ts deleted file mode 100644 index 9338ab5..0000000 --- a/src/cache.ts +++ /dev/null @@ -1,115 +0,0 @@ -interface Node<K, V> { - key: K; - value: V; - newer: Node<K, V> | null; - older: Node<K, V> | null; -} - -function defaultDispose() {} - -export class Cache<K = any, V = any> { - private map = new Map<K, Node<K, V>>(); - private newest: Node<K, V> | null = null; - private oldest: Node<K, V> | null = null; - - constructor( - private max = Infinity, - public dispose: (value: V, key: K) => void = defaultDispose, - ) {} - - public has(key: K): boolean { - return this.map.has(key); - } - - public get(key: K): V | undefined { - const node = this.getNode(key); - return node && node.value; - } - - private getNode(key: K): Node<K, V> | undefined { - const node = this.map.get(key); - - if (node && node !== this.newest) { - const { older, newer } = node; - - if (newer) { - newer.older = older; - } - - if (older) { - older.newer = newer; - } - - node.older = this.newest; - node.older!.newer = node; - - node.newer = null; - this.newest = node; - - if (node === this.oldest) { - this.oldest = newer; - } - } - - return node; - } - - public set(key: K, value: V): V { - let node = this.getNode(key); - if (node) { - return node.value = value; - } - - node = { - key, - value, - newer: null, - older: this.newest - }; - - if (this.newest) { - this.newest.newer = node; - } - - this.newest = node; - this.oldest = this.oldest || node; - - this.map.set(key, node); - - return node.value; - } - - public clean() { - while (this.oldest && this.map.size > this.max) { - this.delete(this.oldest.key); - } - } - - public delete(key: K): boolean { - const node = this.map.get(key); - if (node) { - if (node === this.newest) { - this.newest = node.older; - } - - if (node === this.oldest) { - this.oldest = node.newer; - } - - if (node.newer) { - node.newer.older = node.older; - } - - if (node.older) { - node.older.newer = node.newer; - } - - this.map.delete(key); - this.dispose(node.value, key); - - return true; - } - - return false; - } -} diff --git a/src/helpers.ts b/src/helpers.ts index 1a4dad5..0c01ebc 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,3 +1,5 @@ +export type NoInfer<T> = [T][T extends any ? 0 : never]; + export const { hasOwnProperty, } = Object.prototype; diff --git a/src/index.ts b/src/index.ts index 6b0ae5e..7a7a0b4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,9 @@ import { Trie } from "@wry/trie"; -import { Cache } from "./cache.js"; +import { StrongCache, CommonCache } from "@wry/caches"; import { Entry, AnyEntry } from "./entry.js"; import { parentEntrySlot } from "./context.js"; +import type { NoInfer } from "./helpers.js"; // These helper functions are important for making optimism work with // asynchronous code. In order to register parent-child dependencies, @@ -55,7 +56,7 @@ export type OptimisticWrapperFunction< readonly size: number; // Snapshot of wrap options used to create this wrapper function. - options: OptimisticWrapOptions<TArgs, TKeyArgs, TCacheKey>; + options: OptionsWithCacheInstance<TArgs, TKeyArgs, TCacheKey>; // "Dirty" any cached Entry stored for the given arguments, marking that Entry // and its ancestors as potentially needing to be recomputed. The .dirty(...) @@ -89,10 +90,16 @@ export type OptimisticWrapperFunction< makeCacheKey: (...args: TKeyArgs) => TCacheKey; }; +export { CommonCache } +export interface CommonCacheConstructor<TCacheKey, TResult, TArgs extends any[]> extends Function { + new <K extends TCacheKey, V extends Entry<TArgs, TResult>>(max?: number, dispose?: (value: V, key?: K) => void): CommonCache<K,V>; +} + export type OptimisticWrapOptions< TArgs extends any[], TKeyArgs extends any[] = TArgs, TCacheKey = any, + TResult = any, > = { // The maximum number of cache entries that should be retained before the // cache begins evicting the oldest ones. @@ -103,13 +110,24 @@ export type OptimisticWrapOptions< // The makeCacheKey function takes the same arguments that were passed to // the wrapper function and returns a single value that can be used as a key // in a Map to identify the cached result. - makeCacheKey?: (...args: TKeyArgs) => TCacheKey; + makeCacheKey?: (...args: NoInfer<TKeyArgs>) => TCacheKey; // If provided, the subscribe function should either return an unsubscribe // function or return nothing. subscribe?: (...args: TArgs) => void | (() => any); + cache?: CommonCache<NoInfer<TCacheKey>, Entry<NoInfer<TArgs>, NoInfer<TResult>>> + | CommonCacheConstructor<NoInfer<TCacheKey>, NoInfer<TResult>, NoInfer<TArgs>>; }; -const caches = new Set<Cache<any, AnyEntry>>(); +export interface OptionsWithCacheInstance< + TArgs extends any[], + TKeyArgs extends any[] = TArgs, + TCacheKey = any, + TResult = any, +> extends OptimisticWrapOptions<TArgs, TKeyArgs, TCacheKey, TResult> { + cache: CommonCache<NoInfer<TCacheKey>, Entry<NoInfer<TArgs>, NoInfer<TResult>>>; +}; + +const caches = new Set<CommonCache<any, AnyEntry>>(); export function wrap< TArgs extends any[], @@ -118,14 +136,15 @@ export function wrap< TCacheKey = any, >(originalFunction: (...args: TArgs) => TResult, { max = Math.pow(2, 16), - makeCacheKey = defaultMakeCacheKey, + makeCacheKey = (defaultMakeCacheKey as () => TCacheKey), keyArgs, subscribe, -}: OptimisticWrapOptions<TArgs, TKeyArgs> = Object.create(null)) { - const cache = new Cache<TCacheKey, Entry<TArgs, TResult>>( - max, - entry => entry.dispose(), - ); + cache: cacheOption = StrongCache, +}: OptimisticWrapOptions<TArgs, TKeyArgs, TCacheKey, TResult> = Object.create(null)) { + const cache: CommonCache<TCacheKey, Entry<TArgs, TResult>> = + typeof cacheOption === "function" + ? new cacheOption(max, entry => entry.dispose()) + : cacheOption; const optimistic = function (): TResult { const key = makeCacheKey.apply( @@ -168,9 +187,7 @@ export function wrap< } as OptimisticWrapperFunction<TArgs, TResult, TKeyArgs, TCacheKey>; Object.defineProperty(optimistic, "size", { - get() { - return cache["map"].size; - }, + get: () => cache.size, configurable: false, enumerable: false, }); @@ -180,6 +197,7 @@ export function wrap< makeCacheKey, keyArgs, subscribe, + cache, }); function dirtyKey(key: TCacheKey) { diff --git a/src/tests/api.ts b/src/tests/api.ts index 1d6b852..985765a 100644 --- a/src/tests/api.ts +++ b/src/tests/api.ts @@ -4,6 +4,7 @@ import { wrap, defaultMakeCacheKey, OptimisticWrapperFunction, + CommonCache, } from "../index"; import { wrapYieldingFiberMethods } from '@wry/context'; import { dep } from "../dep"; @@ -36,6 +37,68 @@ describe("optimism", function () { assert.strictEqual(test("a"), "aNaCl"); }); + it("can manually specify a cache instance", () => { + class Cache<K, V> implements CommonCache<K, V> { + private _cache = new Map<K, V>() + has = this._cache.has.bind(this._cache); + get = this._cache.get.bind(this._cache); + delete = this._cache.delete.bind(this._cache); + get size(){ return this._cache.size } + set(key: K, value: V): V { + this._cache.set(key, value); + return value; + } + clean(){}; + } + + const cache = new Cache<String, any>(); + + const wrapped = wrap( + (obj: { value: string }) => obj.value + " transformed", + { + cache, + makeCacheKey(obj) { + return obj.value; + }, + } + ); + assert.ok(cache instanceof Cache); + assert.strictEqual(wrapped({ value: "test" }), "test transformed"); + assert.strictEqual(wrapped({ value: "test" }), "test transformed"); + cache.get("test").value[0] = "test modified"; + assert.strictEqual(wrapped({ value: "test" }), "test modified"); + }); + + it("can manually specify a cache constructor", () => { + class Cache<K, V> implements CommonCache<K, V> { + private _cache = new Map<K, V>() + has = this._cache.has.bind(this._cache); + get = this._cache.get.bind(this._cache); + delete = this._cache.delete.bind(this._cache); + get size(){ return this._cache.size } + set(key: K, value: V): V { + this._cache.set(key, value); + return value; + } + clean(){}; + } + + const wrapped = wrap( + (obj: { value: string }) => obj.value + " transformed", + { + cache: Cache, + makeCacheKey(obj) { + return obj.value; + }, + } + ); + assert.ok(wrapped.options.cache instanceof Cache); + assert.strictEqual(wrapped({ value: "test" }), "test transformed"); + assert.strictEqual(wrapped({ value: "test" }), "test transformed"); + wrapped.options.cache.get("test").value[0] = "test modified"; + assert.strictEqual(wrapped({ value: "test" }), "test modified"); + }); + it("works with two layers of functions", function () { const files: { [key: string]: string } = { "a.js": "a", @@ -692,7 +755,7 @@ describe("optimism", function () { assert.strictEqual(sumFirst.forget(9), false); }); - it("exposes optimistic.size property, returning cache.map.size", function () { + it("exposes optimistic.{size,options.cache.size} properties", function () { const d = dep<string>(); const fib = wrap((n: number): number => { d("shared"); @@ -703,7 +766,12 @@ describe("optimism", function () { }, }); - assert.strictEqual(fib.size, 0); + function size() { + assert.strictEqual(fib.options.cache.size, fib.size); + return fib.size; + } + + assert.strictEqual(size(), 0); assert.strictEqual(fib(0), 0); assert.strictEqual(fib(1), 1); @@ -715,22 +783,22 @@ describe("optimism", function () { assert.strictEqual(fib(7), 13); assert.strictEqual(fib(8), 21); - assert.strictEqual(fib.size, 9); + assert.strictEqual(size(), 9); fib.dirty(6); // Merely dirtying an Entry does not remove it from the LRU cache. - assert.strictEqual(fib.size, 9); + assert.strictEqual(size(), 9); fib.forget(6); // Forgetting an Entry both dirties it and removes it from the LRU cache. - assert.strictEqual(fib.size, 8); + assert.strictEqual(size(), 8); fib.forget(4); - assert.strictEqual(fib.size, 7); + assert.strictEqual(size(), 7); // This way of calling d.dirty causes any parent Entry objects to be // forgotten (removed from the LRU cache). d.dirty("shared", "forget"); - assert.strictEqual(fib.size, 0); + assert.strictEqual(size(), 0); }); }); diff --git a/src/tests/cache.ts b/src/tests/cache.ts index 89d6778..17a5223 100644 --- a/src/tests/cache.ts +++ b/src/tests/cache.ts @@ -1,9 +1,9 @@ import * as assert from "assert"; -import { Cache } from "../cache"; +import { StrongCache as Cache } from "@wry/caches"; describe("least-recently-used cache", function () { it("can hold lots of elements", function () { - const cache = new Cache; + const cache = new Cache(); const count = 1000000; for (let i = 0; i < count; ++i) { @@ -72,8 +72,10 @@ describe("least-recently-used cache", function () { if (sequence.length > 0) { assert.strictEqual((cache as any).newest.key, sequence[0]); - assert.strictEqual((cache as any).oldest.key, - sequence[sequence.length - 1]); + assert.strictEqual( + (cache as any).oldest.key, + sequence[sequence.length - 1] + ); } }