From b214b0dcc65e5fdf442fa337d74fccc679a8724c Mon Sep 17 00:00:00 2001 From: Ethan Niser Date: Fri, 22 Sep 2023 01:42:03 -0500 Subject: [PATCH] finalize persist api and write 3/4 of tests --- packages/beth-stack/src/cache/persist copy.ts | 88 ++- .../beth-stack/src/cache/tests/test.test.tsx | 580 ++++++++++++++++++ tsconfig.json | 4 +- 3 files changed, 646 insertions(+), 26 deletions(-) create mode 100644 packages/beth-stack/src/cache/tests/test.test.tsx diff --git a/packages/beth-stack/src/cache/persist copy.ts b/packages/beth-stack/src/cache/persist copy.ts index 1549400..0ff4101 100644 --- a/packages/beth-stack/src/cache/persist copy.ts +++ b/packages/beth-stack/src/cache/persist copy.ts @@ -7,6 +7,10 @@ type Brand = K & { __brand: T }; type FunctionKey = Brand; type ArgKey = Brand; +type DeepPartial = { + [P in keyof T]?: T[P] extends object ? DeepPartial : T[P]; +}; + export type CacheOptions = { persist?: "memory" | "json"; revalidate?: number; @@ -22,11 +26,32 @@ export type CacheOptions = { }; export type GlobalCacheConfig = { - log?: "debug" | "major" | "none"; + log: "debug" | "major" | "none"; defaultCacheOptions: Required; - returnStaleWhileRevalidate: boolean; - onRevalidateErrorReturnStale: boolean; - rethrowOnUnseededError: boolean; + errorHandling: Required; + returnStaleWhileRevalidating: boolean; +}; + +type ErrorHandlingConfig = { + duringRevalidation?: "return-stale" | "rethrow" | "rerun on next call"; + duringRevalidationWhileUnseeded?: "rerun on next call" | "rethrow"; + duringImmediateSeed?: "rerun on next call" | "rethrow"; +}; + +const startingConfig: GlobalCacheConfig = { + log: "major", + defaultCacheOptions: { + persist: "json", + revalidate: Infinity, + tags: [], + seedImmediately: true, + }, + errorHandling: { + duringRevalidation: "return-stale", + duringRevalidationWhileUnseeded: "rerun on next call", + duringImmediateSeed: "rerun on next call", + }, + returnStaleWhileRevalidating: true, }; export declare function persistedCache Promise>( @@ -38,8 +63,9 @@ export declare function persistedCache Promise>( // returns promise that resolves when all data with the tag have completed revalidation export declare function revalidateTag(tag: string): Promise; +// if null, will use default config export declare function setGlobalPersistCacheConfig( - config: Partial, + config: DeepPartial | null, ): void; type StoredCache = { @@ -53,7 +79,14 @@ class BethMemoryCache implements StoredCache { this.cache = new Map(); } public get(key: string) { - return this.cache.get(key); + const result = this.cache.get(key); + if (result) { + return result; + } else { + throw new Error( + `No entry found in memory cache when one was expected: ${key}`, + ); + } } public set(key: string, value: any) { this.cache.set(key, value); @@ -64,37 +97,44 @@ class BethJsonCache implements StoredCache { constructor() { this.db = new Database("beth-cache.json"); this.db.exec( - "CREATE TABLE IF NOT EXISTS cache (key TEXT PRIMARY KEY, value TEXT)", + "CREATE TABLE IF NOT EXISTS cache (key TEXT PRIMARY KEY, value TEXT NOT NULL)", ); } public get(key: string) { - const result = this.db.get("SELECT value FROM cache WHERE key = ?", key); + const result = this.db + .query("SELECT value FROM cache WHERE key = ?") + .get(key) as { value: string } | undefined; if (result) { return JSON.parse(result.value); } else { - throw new CacheNotFound( + throw new Error( `No entry found in json cache when one was expected: ${key}`, - "json", ); } } public set(key: string, value: any) { - this.db.run( - "INSERT OR REPLACE INTO cache (key, value) VALUES (?, ?)", - key, - JSON.stringify(value), - ); + this.db + .query( + ` + INSERT INTO cache (key, value) + VALUES (?, ?) + ON CONFLICT (key) DO UPDATE SET value = excluded.value; + `, + ) + .run(key, JSON.stringify(value)); } } class InvariantError extends Error { constructor(message: string) { - super(`${message} - THIS SHOULD NEVER HAPPEN - PLEASE OPEN AN ISSUE`); + super( + `${message} - THIS SHOULD NEVER HAPPEN - PLEASE OPEN AN ISSUE ethanniser/beth-stack`, + ); } } export class BethPersistedCache { - private config: GlobalCacheConfig; + private config: GlobalCacheConfig = startingConfig; private primaryMap: Map< FunctionKey, { @@ -114,13 +154,13 @@ export class BethPersistedCache { } >; } - >; - private pendingMap: Map>; - private erroredMap: Map; - private inMemoryDataCache: BethMemoryCache; - private jsonDataCache: BethJsonCache; - private intervals: Set; - private keys: Set; + > = new Map(); + private pendingMap: Map> = new Map(); + private erroredMap: Map = new Map(); + private inMemoryDataCache: BethMemoryCache = new BethMemoryCache(); + private jsonDataCache: BethJsonCache = new BethJsonCache(); + private intervals: Set = new Set(); + private keys: Set = new Set(); constructor() {} diff --git a/packages/beth-stack/src/cache/tests/test.test.tsx b/packages/beth-stack/src/cache/tests/test.test.tsx new file mode 100644 index 0000000..3218f7f --- /dev/null +++ b/packages/beth-stack/src/cache/tests/test.test.tsx @@ -0,0 +1,580 @@ +import "../../shared/global"; +import { beforeEach, describe, expect, test } from "bun:test"; +import { + persistedCache, + revalidateTag, + setGlobalPersistCacheConfig, +} from "../persist copy"; +import "../../jsx/register"; +import { renderToString } from "../../jsx/render"; + +beforeEach(() => { + setGlobalPersistCacheConfig(null); +}); + +let cacheKey = 0; +function getCacheKey() { + return (cacheKey++).toString(); +} + +describe("basic operations", () => { + test("throws on duplicate key", () => { + let getCount = async () => 1; + const _ = persistedCache(getCount, "cache key"); + let error; + try { + persistedCache(getCount, "cache key"); + } catch (e) { + error = e; + } + expect(error).toBe(Error); + }); + + test("dedupes like normal 'cache'", async () => { + let count = 0; + let getCount = async () => ++count; + let cachedGetCount = persistedCache(getCount, getCacheKey()); + + const Component = async () => { + const count = await cachedGetCount(); + return
{count}
; + }; + + const html = await renderToString(() => ( +
+ + + +
+ )); + + expect(html).toBe( + ` +
+
1
+
1
+
1
+
+ `.replace(/\s+/g, ""), + ); + }); + + test("holds value between renders", async () => { + let count = 0; + let getCount = async () => ++count; + let cachedGetCount = persistedCache(getCount, getCacheKey()); + + const Component = async () => { + const count = await cachedGetCount(); + return
{count}
; + }; + + const html = await renderToString(() => ( +
+ + + +
+ )); + + expect(html).toBe( + ` +
+
1
+
1
+
1
+
+ `.replace(/\s+/g, ""), + ); + + const html2 = await renderToString(() => ( +
+ + + +
+ )); + + expect(html2).toBe( + ` +
+
1
+
1
+
1
+
+ `.replace(/\s+/g, ""), + ); + }); +}); + +describe("immediate seeding", () => { + test("by default the cache function is run immediately", async () => { + let count = 0; + let getCount = async () => ++count; + let cachedGetCount = persistedCache(getCount, getCacheKey()); + + expect(count).toBe(1); + + const Component = async () => { + const count = await cachedGetCount(); + return
{count}
; + }; + + const html = await renderToString(() => ( +
+ + + +
+ )); + + expect(html).toBe( + ` +
+
1
+
1
+
1
+
+ `.replace(/\s+/g, ""), + ); + }); + test("can be disabled to not", async () => { + let count = 0; + let getCount = async () => ++count; + let cachedGetCount = persistedCache(getCount, getCacheKey(), { + seedImmediately: false, + }); + + expect(count).toBe(0); + + const Component = async () => { + const count = await cachedGetCount(); + return
{count}
; + }; + + const html = await renderToString(() => ( +
+ + + +
+ )); + + expect(html).toBe( + ` +
+
1
+
1
+
1
+
+ `.replace(/\s+/g, ""), + ); + }); +}); + +describe("interval revalidation", () => { + test("by default interval is off", async () => { + let count = 0; + let getCount = async () => ++count; + let cachedGetCount = persistedCache(getCount, getCacheKey()); + + expect(count).toBe(1); + + const Component = async () => { + const count = await cachedGetCount(); + return
{count}
; + }; + + const html = await renderToString(() => ( +
+ + + +
+ )); + + expect(html).toBe( + ` +
+
1
+
1
+
1
+
+ `.replace(/\s+/g, ""), + ); + + await new Promise((resolve) => + setTimeout(async () => { + const html2 = await renderToString(() => ( + <> + + + + )); + + expect(html2).toBe(`
1
1
`); + + resolve(void 0); + }, 1100), + ); + }); + test("interval reruns callback", async () => { + let count = 0; + const getCount = async () => ++count; + const cachedGetCount = persistedCache(getCount, getCacheKey(), { + revalidate: 1, + }); + + const Component = async () => { + const count = await cachedGetCount(); + return
{count}
; + }; + + const html = await renderToString(() => ( + <> + + + + )); + + expect(html).toBe(`
1
1
`); + + count++; + + // should the be same right away + + const html2 = await renderToString(() => ( + <> + + + + )); + + expect(html2).toBe(`
1
1
`); + + // and the same until a second has passed + + await new Promise((resolve) => + setTimeout(async () => { + const html3 = await renderToString(() => ( + <> + + + + )); + + expect(html3).toBe(`
1
1
`); + + resolve(void 0); + }, 500), + ); + + // but after a second it should be different + + await new Promise((resolve) => + setTimeout(async () => { + const html3 = await renderToString(() => ( + <> + + + + )); + + expect(html3).toBe(`
3
3
`); + + resolve(void 0); + }, 1100), + ); + }); + test("interval with Infinity or 0 is ignored", async () => { + let count = 0; + const getCount = async () => ++count; + + // Infinity revalidation interval + const cachedGetCountInfinity = persistedCache(getCount, getCacheKey(), { + revalidate: Infinity, + }); + + // 0 revalidation interval + const cachedGetCountZero = persistedCache(getCount, getCacheKey(), { + revalidate: 0, + }); + + const ComponentInfinity = async () => { + const count = await cachedGetCountInfinity(); + return
{count}
; + }; + + const ComponentZero = async () => { + const count = await cachedGetCountZero(); + return
{count}
; + }; + + const htmlInfinity = await renderToString(() => ); + const htmlZero = await renderToString(() => ); + + expect(htmlInfinity).toBe(`
1
`); + expect(htmlZero).toBe(`
2
`); + + // Increment count and wait for a short period + count++; + + await new Promise((resolve) => setTimeout(resolve, 1100)); + + const htmlInfinityAfterDelay = await renderToString(() => ( + + )); + const htmlZeroAfterDelay = await renderToString(() => ); + + // Even after the delay, the rendered values should not change as the revalidation interval is Infinity or 0. + expect(htmlInfinityAfterDelay).toBe(`
1
`); + expect(htmlZeroAfterDelay).toBe(`
2
`); + }); +}); + +// TODO +describe("manual revalidation", () => { + test("by default no tags are applied", async () => {}); + test("custom tags can be applied", async () => {}); + test("tags can be revalidated", async () => {}); + test("tags stay independant", async () => {}); + test("two entries with shared tag both revalidate", async () => {}); + test("revalidateTag returns promise that resolves when revalidation is complete", async () => {}); +}); + +describe("pending behavior (swr)", () => { + test("by default SWR is on", async () => { + let count = 0; + let getCount = async () => + new Promise((resolve) => setTimeout(() => resolve(++count), 200)); + let cachedGetCount = persistedCache(getCount, getCacheKey(), { + revalidate: 1, + }); + + expect(count).toBe(1); + + const Component = async () => { + const count = await cachedGetCount(); + return
{count}
; + }; + + await new Promise((resolve) => setTimeout(resolve, 1050)); + + // should hit during the pending revlidation + // but still get the stale value + + const html = await renderToString(() => ( +
+ + + +
+ )); + + expect(html).toBe( + ` +
+
1
+
1
+
1
+
+ `.replace(/\s+/g, ""), + ); + }); + test("but can be disabled to return pending value", async () => { + setGlobalPersistCacheConfig({ + returnStaleWhileRevalidating: false, + }); + let count = 0; + let getCount = async () => + new Promise((resolve) => setTimeout(() => resolve(++count), 200)); + let cachedGetCount = persistedCache(getCount, getCacheKey(), { + revalidate: 1, + }); + + expect(count).toBe(1); + + const Component = async () => { + const count = await cachedGetCount(); + return
{count}
; + }; + + await new Promise((resolve) => setTimeout(resolve, 1050)); + + // should hit during the pending revlidation + // and should recieve the pending value + + const html = await renderToString(() => ( +
+ + + +
+ )); + + expect(html).toBe( + ` +
+
2
+
2
+
2
+
+ `.replace(/\s+/g, ""), + ); + }); + test("the pending promise can reject", async () => { + setGlobalPersistCacheConfig({ + returnStaleWhileRevalidating: false, + }); + let count = 0; + let getCount = async () => + new Promise((resolve, reject) => + setTimeout(() => { + if (++count === 2) { + reject(new Error("error")); + } + resolve(count); + }, 200), + ); + let cachedGetCount = persistedCache(getCount, getCacheKey(), { + revalidate: 1, + }); + + expect(count).toBe(1); + + const Component = async () => { + const count = await cachedGetCount(); + return
{count}
; + }; + + await new Promise((resolve) => setTimeout(resolve, 1050)); + + // should hit during the pending revlidation + // and should recieve the pending value (which rejects) + + const html = renderToString(() => ( +
+ + + +
+ )); + + expect(html).rejects.toBe(Error); + }); +}); + +describe("error in immediate seed", () => { + test("by default it will remain unseeded and be rerun", async () => { + let count = 0; + let getCount = async () => { + if (count++ === 1) { + throw new Error("error"); + } + return count; + }; + let cachedGetCount = persistedCache(getCount, getCacheKey()); + + expect(count).toBe(1); + + const Component = async () => { + const count = await cachedGetCount(); + return
{count}
; + }; + + const html = await renderToString(() => ( +
+ + + +
+ )); + + expect(html).toBe( + ` +
+
2
+
2
+
2
+
+ `.replace(/\s+/g, ""), + ); + }); + test("can be disabled to rethrow", async () => { + setGlobalPersistCacheConfig({ + errorHandling: { + duringImmediateSeed: "rethrow", + }, + }); + + let count = 0; + let getCount = async () => { + if (count++ === 1) { + throw new Error("error"); + } + return count; + }; + let cachedGetCount = persistedCache(getCount, getCacheKey(), {}); + + expect(count).toBe(1); + + const Component = async () => { + const count = await cachedGetCount(); + return
{count}
; + }; + + const html = () => + renderToString(() => ( +
+ + + +
+ )); + + expect(html).toThrow(); + + // after rethrowing the error, the function should be rerun + + const html2 = await renderToString(() => ( +
+ + + +
+ )); + + expect(html2).toBe( + ` +
+
2
+
2
+
2
+
+ `.replace(/\s+/g, ""), + ); + }); +}); + +// TODO +describe("error in unseeded revalidation", () => { + test("by default it will remain unseeded and be rerun", async () => {}); + test("can be disabled to rethrow", async () => {}); +}); + +// TODO +describe("error in seeded revalidation", () => { + test("by default it will return last valid data", async () => {}); + test("can be disabled to rethrow", async () => {}); + test("can be disabled to rerun", async () => {}); +}); + +// TODO +describe("argumentitive functions", () => { + test("works with functions with arguments", async () => {}); + test("different sets of arguments are cached seperately", async () => {}); + test("different sets of arguments are compared with deepStrictEqual", async () => {}); + test("revalidating reruns with all stored sets of arguments", async () => {}); + test("can be seeded immediately with arguments", async () => {}); + test("can be seeded immediately with multiple sets of arguments", async () => {}); +}); diff --git a/tsconfig.json b/tsconfig.json index 65141ee..471de1b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -37,8 +37,8 @@ "noFallthroughCasesInSwitch": true, "noImplicitOverride": true, "noImplicitReturns": true, - "noUnusedLocals": true, - "noUnusedParameters": true, + // "noUnusedLocals": true, + // "noUnusedParameters": true, "useUnknownInCatchVariables": true, "noUncheckedIndexedAccess": true, // TLDR - Checking an indexed value (array[0]) now forces type as there is no confirmation that index exists // THE BELOW ARE EXTRA STRICT OPTIONS THAT SHOULD ONLY BY CONSIDERED IN VERY SAFE PROJECTS