diff --git a/.gitignore b/.gitignore index 65392ed..d609405 100644 --- a/.gitignore +++ b/.gitignore @@ -171,4 +171,6 @@ dist tsconfig.tsbuildinfo local.sqlite local.sqlite-shm -local.sqlite-wal \ No newline at end of file +local.sqlite-wal + +.beth/ \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index 6943ffe..00e77da 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index eec09f7..7c76b99 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,6 @@ "@elysiajs/cron": "^0.6.0", "@elysiajs/static": "^0.6.0", "@elysiajs/swagger": "^0.6.2", - "@kitajs/html": "^2.1.2", "@libsql/client": "0.3.5-pre.4", "@lucia-auth/adapter-sqlite": "^2.0.0", "@t3-oss/env-core": "^0.6.1", diff --git a/src/beth/cache.ts b/src/beth/cache.ts index d15e4aa..23322eb 100644 --- a/src/beth/cache.ts +++ b/src/beth/cache.ts @@ -14,28 +14,29 @@ class BethPersistCache { private inMemoryDataCache: Map; private jsonDataCache: Database; private intervals: Set; + private keys: Set; constructor() { this.callBackMap = new Map(); this.inMemoryDataCache = new Map(); - this.jsonDataCache = new Database("beth-cache.sqlite"); + this.jsonDataCache = new Database("./.beth/beth-cache.sqlite"); this.intervals = new Set(); this.pendingMap = new Map(); + this.keys = new Set(); - this.jsonDataCache.exec(` + this.jsonDataCache.run(` DROP TABLE IF EXISTS cache; `); - this.jsonDataCache.exec(` + this.jsonDataCache.run(` CREATE TABLE cache ( key TEXT PRIMARY KEY, - value TEXT NOT NULL, + value TEXT NOT NULL ); `); } private setJsonCache(key: string, value: any) { - console.log("Seeding JSON Cache:", key, value); this.jsonDataCache.run( ` INSERT INTO cache (key, value) @@ -46,18 +47,17 @@ class BethPersistCache { ); } - private async getJsonCache(key: string) { + private getJsonCache(key: string) { console.log("JSON Cache HIT:", key); - const result = (await this.jsonDataCache + const result = this.jsonDataCache .query("SELECT value FROM cache WHERE key = ?") - .get(key)) as { value: string } | undefined; + .get(key) as { value: string } | undefined; if (!result) throw new Error("JSON Cache Miss"); return JSON.parse(result.value); } public seed({ key, - value, tags, revalidate, callBack, @@ -66,31 +66,51 @@ class BethPersistCache { cache: "memory" | "json"; callBack: () => Promise; key: string; - value: any; tags: string[]; revalidate: number; }) { - console.log(`Seeding ${cache} Cache:`, key, value); - if (cache === "memory") { - this.inMemoryDataCache.set(key, value); - } else if (cache === "json") { - this.setJsonCache(key, value); + if (this.keys.has(key)) { + throw new Error( + `Persistant Cache Key already exists: ${key} - these much be unqiue across your entire app` + ); + } else { + this.keys.add(key); } - this.callBackMap.set(key, { - callBack, - tags, - cache, + + const promise = callBack(); + this.pendingMap.set(key, promise); + + promise.then((value) => { + this.pendingMap.delete(key); + console.log(`Seeding ${cache} Cache:`, key, value); + if (cache === "memory") { + this.inMemoryDataCache.set(key, value); + } else if (cache === "json") { + this.setJsonCache(key, value); + } + this.callBackMap.set(key, { + callBack, + tags, + cache, + }); + if (revalidate > 0) { + this.setInterval(key, revalidate); + } }); - if (revalidate > 0) { - console.log("Setting Revalidate Interval:", key, revalidate); - this.setInterval(key, revalidate); - } } private rerunCallBack(key: string) { + const pending = this.pendingMap.get(key); + if (pending) { + console.log("PENDING CACHE HIT:", key); + return pending; + } + console.log("rerunning callback:", key); const result = this.callBackMap.get(key); - if (!result) return; + if (!result) { + throw new Error("No callback found for key: " + key); + } const { callBack, tags, cache } = result; const callBackPromise = callBack(); this.pendingMap.set(key, callBackPromise); @@ -111,15 +131,19 @@ class BethPersistCache { } private setInterval(key: string, revalidate: number) { - if (revalidate === Infinity) return; - const interval = setInterval(async () => { + if (revalidate === Infinity) { + console.log("No revalidate interval for:", key); + return; + } + const interval = setInterval(() => { console.log(`Cache Revalidating (on ${revalidate}s interval):`, key); this.rerunCallBack(key); - }, revalidate); + }, revalidate * 1000); + console.log("Setting Revalidate Interval:", key, revalidate); this.intervals.add(interval); } - private async getMemoryCache(key: string) { + private getMemoryCache(key: string) { const cacheResult = this.inMemoryDataCache.get(key); if (cacheResult) { console.log("Memory Cache HIT:", key); @@ -142,7 +166,7 @@ class BethPersistCache { try { const pending = this.pendingMap.get(key); if (pending) { - console.log("STALE HIT, returning pending promise:", key); + console.log("PENDING CACHE HIT:", key); return pending; } @@ -185,15 +209,12 @@ export function persistedCache Promise>( const tags = options?.tags ?? []; console.log("Cache MISS: ", key); - callBack().then((result) => { - GLOBAL_CACHE.seed({ - callBack, - key, - value: result, - tags, - revalidate, - cache: persist, - }); + GLOBAL_CACHE.seed({ + callBack, + key, + tags, + revalidate, + cache: persist, }); return cache(() => GLOBAL_CACHE.getCachedValue(key, persist)) as T; } diff --git a/src/beth/test/cache.test.tsx b/src/beth/test/cache.test.tsx new file mode 100644 index 0000000..017d5dc --- /dev/null +++ b/src/beth/test/cache.test.tsx @@ -0,0 +1,306 @@ +import { test, expect } from "bun:test"; +import { persistedCache, revalidateTag } from "../cache"; +import "beth-jsx/register"; +import { renderToString } from "beth-jsx"; + +test("static json cache", async () => { + let count = 0; + const getCount = async () => ++count; + const cachedGetCount = persistedCache(getCount, "getCount1"); + + const Component = async () => { + const data = await cachedGetCount(); + return

number: {data}

; + }; + + const html = await renderToString(() => ( + <> + + + + )); + + expect(html).toBe(`

number: 1

number: 1

`); + + // even in a new render we get the same results + const Test = () => ; + + const html3 = await renderToString(() => ( + <> + + + + )); + + expect(html3).toBe(`

number: 1

number: 1

`); +}); + +test("static memory cache", async () => { + let count = 0; + const getCount = async () => ++count; + const cachedGetCount = persistedCache(getCount, "getCount2", { + persist: "memory", + }); + + const Component = async () => { + const data = await cachedGetCount(); + return

number: {data}

; + }; + + const html = await renderToString(() => ( + <> + + + + )); + + expect(html).toBe(`

number: 1

number: 1

`); + + // even in a new render we get the same results + const Test = () => ; + + const html3 = await renderToString(() => ( + <> + + + + )); + + expect(html3).toBe(`

number: 1

number: 1

`); +}); + +test("json cache revalidate interval", async () => { + let count = 0; + const getCount = async () => ++count; + const cachedGetCount = persistedCache(getCount, "getCount3", { + revalidate: 1, + }); + + const Component = async () => { + const data = await cachedGetCount(); + return

number: {data}

; + }; + + const html = await renderToString(() => ( + <> + + + + )); + + expect(html).toBe(`

number: 1

number: 1

`); + + count++; + + // should the be same right away + + const html2 = await renderToString(() => ( + <> + + + + )); + + expect(html2).toBe(`

number: 1

number: 1

`); + + // and the same until a second has passed + + await new Promise((resolve) => + setTimeout(async () => { + const html3 = await renderToString(() => ( + <> + + + + )); + + expect(html3).toBe(`

number: 1

number: 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(`

number: 3

number: 3

`); + + resolve(void 0); + }, 1100) + ); +}); + +test("memory cache revalidate interval", async () => { + let count = 0; + const getCount = async () => ++count; + const cachedGetCount = persistedCache(getCount, "getCount4", { + persist: "memory", + revalidate: 1, + }); + + const Component = async () => { + const data = await cachedGetCount(); + return

number: {data}

; + }; + + const html = await renderToString(() => ( + <> + + + + )); + + expect(html).toBe(`

number: 1

number: 1

`); + + count++; + + // should the be same right away + + const html2 = await renderToString(() => ( + <> + + + + )); + + expect(html2).toBe(`

number: 1

number: 1

`); + + // and the same until a second has passed + + await new Promise((resolve) => + setTimeout(async () => { + const html3 = await renderToString(() => ( + <> + + + + )); + + expect(html3).toBe(`

number: 1

number: 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(`

number: 3

number: 3

`); + + resolve(void 0); + }, 1100) + ); +}); + +test("json cache revalidate tag", async () => { + let count = 0; + const getCount = async () => ++count; + const cachedGetCount = persistedCache(getCount, "getCount5", { + tags: ["tag1"], + }); + + const Component = async () => { + const data = await cachedGetCount(); + return

number: {data}

; + }; + + const html = await renderToString(() => ( + <> + + + + )); + + expect(html).toBe(`

number: 1

number: 1

`); + + count++; + + // should the be same right away + + const html2 = await renderToString(() => ( + <> + + + + )); + + expect(html2).toBe(`

number: 1

number: 1

`); + + revalidateTag("tag1"); + + // now should be different + + const html3 = await renderToString(() => ( + <> + + + + )); + + expect(html3).toBe(`

number: 3

number: 3

`); +}); + +test("memory cache revalidate tag", async () => { + let count = 0; + const getCount = async () => ++count; + const cachedGetCount = persistedCache(getCount, "getCount6", { + tags: ["tag1"], + persist: "memory", + }); + + const Component = async () => { + const data = await cachedGetCount(); + return

number: {data}

; + }; + + const html = await renderToString(() => ( + <> + + + + )); + + expect(html).toBe(`

number: 1

number: 1

`); + + count++; + + // should the be same right away + + const html2 = await renderToString(() => ( + <> + + + + )); + + expect(html2).toBe(`

number: 1

number: 1

`); + + revalidateTag("tag1"); + + // now should be different + + const html3 = await renderToString(() => ( + <> + + + + )); + + expect(html3).toBe(`

number: 3

number: 3

`); +}); diff --git a/src/components/base.tsx b/src/components/base.tsx index 65f3776..97f83af 100644 --- a/src/components/base.tsx +++ b/src/components/base.tsx @@ -1,4 +1,4 @@ -import { type PropsWithChildren } from "@kitajs/html"; +import { type PropsWithChildren } from "beth-jsx"; import { config } from "../config"; export const BaseHtml = ({ children }: PropsWithChildren) => ( diff --git a/src/context/index.ts b/src/context/index.ts index 9527a6f..3c7ada1 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -3,8 +3,8 @@ import { Elysia } from "elysia"; // import pretty from "pino-pretty"; import { config } from "../config"; import { client, db } from "../db"; -import "@kitajs/html/register"; -import "@kitajs/html/htmx"; +import "beth-jsx/register"; +import "beth-jsx/htmx"; import { auth } from "../auth"; // import { cron } from "@elysiajs/cron";