From 0057ec8fd86661dcb454e4064ca8499702ad3289 Mon Sep 17 00:00:00 2001 From: JMCP Bot Date: Wed, 25 Mar 2026 13:57:12 +0100 Subject: [PATCH] jmcp: Add a request-scoped cache hit/miss counter for observability --- apps/web/app/api/health/route.ts | 3 +- package-lock.json | 127 +------------------------ packages/auth/package.json | 2 +- packages/db/src/cache-counter.ts | 49 ++++++++++ packages/db/src/demo-store.ts | 17 ++++ packages/db/src/index.ts | 3 + packages/db/test/cache-counter.test.ts | 61 ++++++++++++ 7 files changed, 134 insertions(+), 128 deletions(-) create mode 100644 packages/db/src/cache-counter.ts create mode 100644 packages/db/test/cache-counter.test.ts diff --git a/apps/web/app/api/health/route.ts b/apps/web/app/api/health/route.ts index 899dc67..75a2f47 100644 --- a/apps/web/app/api/health/route.ts +++ b/apps/web/app/api/health/route.ts @@ -1,5 +1,5 @@ import { getPapersConfig } from "@papers/config" -import { pingDatabase } from "@papers/db" +import { getCacheStats, pingDatabase } from "@papers/db" import { NextResponse } from "next/server" export const dynamic = "force-dynamic" @@ -26,6 +26,7 @@ export async function GET() { status: allOk ? "ok" : "degraded", demoMode, checks, + cache: getCacheStats(), }, { status: allOk ? 200 : 503 }, ) diff --git a/package-lock.json b/package-lock.json index 6d40624..ee21e60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8084,7 +8084,7 @@ "@papers/config": "0.1.0", "@papers/db": "0.1.0", "better-auth": "^1.5.6", - "drizzle-orm": "^0.44.5", + "drizzle-orm": "^0.45.1", "pg": "^8.16.3" } }, @@ -8319,131 +8319,6 @@ } } }, - "packages/auth/node_modules/drizzle-orm": { - "version": "0.44.7", - "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.44.7.tgz", - "integrity": "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ==", - "license": "Apache-2.0", - "peerDependencies": { - "@aws-sdk/client-rds-data": ">=3", - "@cloudflare/workers-types": ">=4", - "@electric-sql/pglite": ">=0.2.0", - "@libsql/client": ">=0.10.0", - "@libsql/client-wasm": ">=0.10.0", - "@neondatabase/serverless": ">=0.10.0", - "@op-engineering/op-sqlite": ">=2", - "@opentelemetry/api": "^1.4.1", - "@planetscale/database": ">=1.13", - "@prisma/client": "*", - "@tidbcloud/serverless": "*", - "@types/better-sqlite3": "*", - "@types/pg": "*", - "@types/sql.js": "*", - "@upstash/redis": ">=1.34.7", - "@vercel/postgres": ">=0.8.0", - "@xata.io/client": "*", - "better-sqlite3": ">=7", - "bun-types": "*", - "expo-sqlite": ">=14.0.0", - "gel": ">=2", - "knex": "*", - "kysely": "*", - "mysql2": ">=2", - "pg": ">=8", - "postgres": ">=3", - "sql.js": ">=1", - "sqlite3": ">=5" - }, - "peerDependenciesMeta": { - "@aws-sdk/client-rds-data": { - "optional": true - }, - "@cloudflare/workers-types": { - "optional": true - }, - "@electric-sql/pglite": { - "optional": true - }, - "@libsql/client": { - "optional": true - }, - "@libsql/client-wasm": { - "optional": true - }, - "@neondatabase/serverless": { - "optional": true - }, - "@op-engineering/op-sqlite": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@planetscale/database": { - "optional": true - }, - "@prisma/client": { - "optional": true - }, - "@tidbcloud/serverless": { - "optional": true - }, - "@types/better-sqlite3": { - "optional": true - }, - "@types/pg": { - "optional": true - }, - "@types/sql.js": { - "optional": true - }, - "@upstash/redis": { - "optional": true - }, - "@vercel/postgres": { - "optional": true - }, - "@xata.io/client": { - "optional": true - }, - "better-sqlite3": { - "optional": true - }, - "bun-types": { - "optional": true - }, - "expo-sqlite": { - "optional": true - }, - "gel": { - "optional": true - }, - "knex": { - "optional": true - }, - "kysely": { - "optional": true - }, - "mysql2": { - "optional": true - }, - "pg": { - "optional": true - }, - "postgres": { - "optional": true - }, - "prisma": { - "optional": true - }, - "sql.js": { - "optional": true - }, - "sqlite3": { - "optional": true - } - } - }, "packages/auth/node_modules/jose": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", diff --git a/packages/auth/package.json b/packages/auth/package.json index 4626c53..ac82834 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -17,7 +17,7 @@ "@papers/config": "0.1.0", "@papers/db": "0.1.0", "better-auth": "^1.5.6", - "drizzle-orm": "^0.44.5", + "drizzle-orm": "^0.45.1", "pg": "^8.16.3" } } diff --git a/packages/db/src/cache-counter.ts b/packages/db/src/cache-counter.ts new file mode 100644 index 0000000..31478a4 --- /dev/null +++ b/packages/db/src/cache-counter.ts @@ -0,0 +1,49 @@ +import { AsyncLocalStorage } from "node:async_hooks" +import type { DemoState } from "./demo-store" + +export type CacheStats = { + hits: number + misses: number +} + +type CacheStore = { + hits: number + misses: number + state: DemoState | null +} + +const storage = new AsyncLocalStorage() + +export function runWithCacheCounter(fn: () => T): T { + return storage.run({ hits: 0, misses: 0, state: null }, fn) +} + +export function getCacheStats(): CacheStats | null { + const store = storage.getStore() + if (!store) return null + return { hits: store.hits, misses: store.misses } +} + +export function getCachedState(): DemoState | null { + return storage.getStore()?.state ?? null +} + +export function setCachedState(state: DemoState): void { + const store = storage.getStore() + if (store) store.state = state +} + +export function recordHit(): void { + const store = storage.getStore() + if (store) store.hits++ +} + +export function recordMiss(): void { + const store = storage.getStore() + if (store) store.misses++ +} + +export function invalidateCachedState(): void { + const store = storage.getStore() + if (store) store.state = null +} diff --git a/packages/db/src/demo-store.ts b/packages/db/src/demo-store.ts index 6090a4b..327315e 100644 --- a/packages/db/src/demo-store.ts +++ b/packages/db/src/demo-store.ts @@ -22,6 +22,13 @@ import type { User, } from "@papers/contracts" import { serializePublicPaper, slugify } from "@papers/contracts" +import { + getCachedState, + invalidateCachedState, + recordHit, + recordMiss, + setCachedState, +} from "./cache-counter" export type DemoState = { users: User[] @@ -745,6 +752,13 @@ function getStorePath(): string { } export async function readDemoState(): Promise { + const cached = getCachedState() + if (cached) { + recordHit() + return cached + } + recordMiss() + const filePath = getStorePath() await mkdir(path.dirname(filePath), { recursive: true }) @@ -783,10 +797,12 @@ export async function readDemoState(): Promise { : initial.groupReadingListItems, } await writeFile(filePath, JSON.stringify(normalized, null, 2)) + setCachedState(normalized) return normalized } catch { const initial = createInitialState() await writeFile(filePath, JSON.stringify(initial, null, 2)) + setCachedState(initial) return initial } } @@ -794,6 +810,7 @@ export async function readDemoState(): Promise { export async function writeDemoState(state: DemoState): Promise { await mkdir(path.dirname(getStorePath()), { recursive: true }) await writeFile(getStorePath(), JSON.stringify(state, null, 2)) + invalidateCachedState() } export function getPublicComments(comments: Comment[], paper: Paper): Comment[] { diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 7611584..ade8c01 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -46,6 +46,9 @@ import { readDemoState, writeDemoState, } from "./demo-store" + +export { type CacheStats, getCacheStats, runWithCacheCounter } from "./cache-counter" + import { authAccounts, authSessions, diff --git a/packages/db/test/cache-counter.test.ts b/packages/db/test/cache-counter.test.ts new file mode 100644 index 0000000..807dc24 --- /dev/null +++ b/packages/db/test/cache-counter.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest" +import { + getCachedState, + getCacheStats, + invalidateCachedState, + recordHit, + recordMiss, + runWithCacheCounter, + setCachedState, +} from "../src/cache-counter" + +describe("cache counter", () => { + it("returns null outside a request scope", () => { + expect(getCacheStats()).toBeNull() + }) + + it("tracks hits and misses within a scope", async () => { + await runWithCacheCounter(async () => { + recordMiss() + recordHit() + recordHit() + + const stats = getCacheStats() + expect(stats).toEqual({ hits: 2, misses: 1 }) + }) + }) + + it("isolates counters between scopes", async () => { + await runWithCacheCounter(async () => { + recordMiss() + expect(getCacheStats()).toEqual({ hits: 0, misses: 1 }) + }) + + await runWithCacheCounter(async () => { + recordMiss() + recordHit() + expect(getCacheStats()).toEqual({ hits: 1, misses: 1 }) + }) + }) + + it("caches and retrieves state within a scope", async () => { + await runWithCacheCounter(async () => { + expect(getCachedState()).toBeNull() + + const fakeState = { users: [] } as never + setCachedState(fakeState) + expect(getCachedState()).toBe(fakeState) + }) + }) + + it("invalidates cached state within a scope", async () => { + await runWithCacheCounter(async () => { + const fakeState = { users: [] } as never + setCachedState(fakeState) + expect(getCachedState()).toBe(fakeState) + + invalidateCachedState() + expect(getCachedState()).toBeNull() + }) + }) +})