From 657f52bf8fd861abd027a7b986c09ee5cbd1f161 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Tue, 20 Jan 2026 01:04:28 +0000 Subject: [PATCH 01/14] feat: setup cache plugin --- packages/plugins/cache/eslint.config.js | 4 ++ packages/plugins/cache/package.json | 55 +++++++++++++++++++++++++ packages/plugins/cache/tsconfig.json | 4 ++ packages/plugins/cache/tsup.config.ts | 13 ++++++ packages/plugins/cache/vitest.config.ts | 4 ++ pnpm-lock.yaml | 50 +++++++++++++++++----- 6 files changed, 119 insertions(+), 11 deletions(-) create mode 100644 packages/plugins/cache/eslint.config.js create mode 100644 packages/plugins/cache/package.json create mode 100644 packages/plugins/cache/tsconfig.json create mode 100644 packages/plugins/cache/tsup.config.ts create mode 100644 packages/plugins/cache/vitest.config.ts diff --git a/packages/plugins/cache/eslint.config.js b/packages/plugins/cache/eslint.config.js new file mode 100644 index 000000000..5698b9910 --- /dev/null +++ b/packages/plugins/cache/eslint.config.js @@ -0,0 +1,4 @@ +import config from '@zenstackhq/eslint-config/base.js'; + +/** @type {import("eslint").Linter.Config} */ +export default config; diff --git a/packages/plugins/cache/package.json b/packages/plugins/cache/package.json new file mode 100644 index 000000000..d06b26800 --- /dev/null +++ b/packages/plugins/cache/package.json @@ -0,0 +1,55 @@ +{ + "name": "@zenstackhq/plugin-cache", + "version": "3.2.1", + "description": "ZenStack Cache Plugin", + "type": "module", + "scripts": { + "build": "tsc --noEmit && tsup-node", + "watch": "tsup-node --watch", + "lint": "eslint src --ext ts", + "pack": "pnpm pack" + }, + "keywords": [], + "author": "ZenStack Team", + "license": "MIT", + "files": [ + "dist" + ], + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./providers/memory": { + "import": { + "types": "./dist/providers/memory.d.ts", + "default": "./dist/providers/memory.js" + }, + "require": { + "types": "./dist/providers/memory.d.cts", + "default": "./dist/providers/memory.cjs" + } + }, + "./package.json": { + "import": "./package.json", + "require": "./package.json" + } + }, + "dependencies": { + "@zenstackhq/common-helpers": "workspace:*", + "@zenstackhq/orm": "workspace:*", + "zod": "catalog:", + "json-stable-stringify": "^1.3.0" + }, + "devDependencies": { + "@zenstackhq/eslint-config": "workspace:*", + "@zenstackhq/typescript-config": "workspace:*", + "@zenstackhq/vitest-config": "workspace:*" + } +} diff --git a/packages/plugins/cache/tsconfig.json b/packages/plugins/cache/tsconfig.json new file mode 100644 index 000000000..41472d086 --- /dev/null +++ b/packages/plugins/cache/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@zenstackhq/typescript-config/base.json", + "include": ["src/**/*"] +} diff --git a/packages/plugins/cache/tsup.config.ts b/packages/plugins/cache/tsup.config.ts new file mode 100644 index 000000000..21b9f0fd9 --- /dev/null +++ b/packages/plugins/cache/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: { + index: 'src/index.ts', + 'providers/memory': 'src/providers/memory.ts', + }, + outDir: 'dist', + splitting: false, + sourcemap: true, + dts: true, + format: ['cjs', 'esm'], +}); diff --git a/packages/plugins/cache/vitest.config.ts b/packages/plugins/cache/vitest.config.ts new file mode 100644 index 000000000..75a9f709c --- /dev/null +++ b/packages/plugins/cache/vitest.config.ts @@ -0,0 +1,4 @@ +import base from '@zenstackhq/vitest-config/base'; +import { defineConfig, mergeConfig } from 'vitest/config'; + +export default mergeConfig(base, defineConfig({})); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b6ee103e..f793eb53a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -572,6 +572,31 @@ importers: specifier: ^4.1.0 version: 4.1.12 + packages/plugins/cache: + dependencies: + '@zenstackhq/common-helpers': + specifier: workspace:* + version: link:../../common-helpers + '@zenstackhq/orm': + specifier: workspace:* + version: link:../../orm + json-stable-stringify: + specifier: ^1.3.0 + version: 1.3.0 + zod: + specifier: 'catalog:' + version: 4.1.12 + devDependencies: + '@zenstackhq/eslint-config': + specifier: workspace:* + version: link:../../config/eslint-config + '@zenstackhq/typescript-config': + specifier: workspace:* + version: link:../../config/typescript-config + '@zenstackhq/vitest-config': + specifier: workspace:* + version: link:../../config/vitest-config + packages/plugins/policy: dependencies: '@zenstackhq/common-helpers': @@ -1034,6 +1059,9 @@ importers: '@zenstackhq/orm': specifier: workspace:* version: link:../../packages/orm + '@zenstackhq/plugin-cache': + specifier: workspace:* + version: link:../../packages/plugins/cache '@zenstackhq/plugin-policy': specifier: workspace:* version: link:../../packages/plugins/policy @@ -8605,7 +8633,7 @@ snapshots: '@babel/types': 7.28.5 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.1 + debug: 4.4.3 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -8744,7 +8772,7 @@ snapshots: '@babel/parser': 7.28.5 '@babel/template': 7.27.2 '@babel/types': 7.28.5 - debug: 4.4.1 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -10884,7 +10912,7 @@ snapshots: '@typescript-eslint/types': 8.46.2 '@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.46.2 - debug: 4.4.1 + debug: 4.4.3 eslint: 9.29.0(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: @@ -10894,7 +10922,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.34.1(typescript@5.9.3) '@typescript-eslint/types': 8.34.1 - debug: 4.4.1 + debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -10903,7 +10931,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.46.2(typescript@5.9.3) '@typescript-eslint/types': 8.46.2 - debug: 4.4.1 + debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -10930,7 +10958,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 8.34.1(typescript@5.9.3) '@typescript-eslint/utils': 8.34.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3) - debug: 4.4.1 + debug: 4.4.3 eslint: 9.29.0(jiti@2.6.1) ts-api-utils: 2.1.0(typescript@5.9.3) typescript: 5.9.3 @@ -10942,7 +10970,7 @@ snapshots: '@typescript-eslint/types': 8.46.2 '@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3) '@typescript-eslint/utils': 8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3) - debug: 4.4.1 + debug: 4.4.3 eslint: 9.29.0(jiti@2.6.1) ts-api-utils: 2.1.0(typescript@5.9.3) typescript: 5.9.3 @@ -10959,7 +10987,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.34.1(typescript@5.9.3) '@typescript-eslint/types': 8.34.1 '@typescript-eslint/visitor-keys': 8.34.1 - debug: 4.4.1 + debug: 4.4.3 fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.5 @@ -10975,7 +11003,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.46.2(typescript@5.9.3) '@typescript-eslint/types': 8.46.2 '@typescript-eslint/visitor-keys': 8.46.2 - debug: 4.4.1 + debug: 4.4.3 fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.5 @@ -11400,7 +11428,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.1 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -12521,7 +12549,7 @@ snapshots: eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.1 + debug: 4.4.3 eslint: 9.29.0(jiti@2.6.1) get-tsconfig: 4.10.1 is-bun-module: 2.0.0 From 63df283590a339919ead3646a5351f6d36c1d7ac Mon Sep 17 00:00:00 2001 From: sanny-io Date: Tue, 20 Jan 2026 01:06:31 +0000 Subject: [PATCH 02/14] feat: add memory provider --- packages/plugins/cache/src/index.ts | 1 + packages/plugins/cache/src/plugin.ts | 69 +++++++++++++++++++ .../plugins/cache/src/providers/memory.ts | 46 +++++++++++++ packages/plugins/cache/src/schemas.ts | 10 +++ packages/plugins/cache/src/types.ts | 34 +++++++++ packages/plugins/cache/src/utils.ts | 21 ++++++ 6 files changed, 181 insertions(+) create mode 100644 packages/plugins/cache/src/index.ts create mode 100644 packages/plugins/cache/src/plugin.ts create mode 100644 packages/plugins/cache/src/providers/memory.ts create mode 100644 packages/plugins/cache/src/schemas.ts create mode 100644 packages/plugins/cache/src/types.ts create mode 100644 packages/plugins/cache/src/utils.ts diff --git a/packages/plugins/cache/src/index.ts b/packages/plugins/cache/src/index.ts new file mode 100644 index 000000000..1110b6451 --- /dev/null +++ b/packages/plugins/cache/src/index.ts @@ -0,0 +1 @@ +export * from './plugin'; diff --git a/packages/plugins/cache/src/plugin.ts b/packages/plugins/cache/src/plugin.ts new file mode 100644 index 000000000..427e2867e --- /dev/null +++ b/packages/plugins/cache/src/plugin.ts @@ -0,0 +1,69 @@ +import { definePlugin } from '@zenstackhq/orm'; +import stableStringify from 'json-stable-stringify'; +import { cacheEnvelopeSchema } from './schemas'; +import type { CacheEnvelope, CacheInvalidationOptions, CachePluginOptions } from './types'; + +export function defineCachePlugin(pluginOptions: CachePluginOptions) { + return definePlugin({ + id: 'cache', + name: 'Cache', + description: 'Optionally caches read queries.', + + queryArgs: { + $read: cacheEnvelopeSchema, + }, + + client: { + $cache: { + invalidate: (options: CacheInvalidationOptions) => { + return pluginOptions.provider.invalidate(options); + }, + + invalidateAll() { + return pluginOptions.provider.invalidateAll(); + }, + }, + }, + + onQuery: async ({ args, model, operation, proceed }) => { + if (args && 'cache' in args) { + const argsWithoutCache: Record = {}; + + for (const [key, value] of Object.entries(args)) { + if (key !== 'cache') { + argsWithoutCache[key] = value; + } + } + + const cache = pluginOptions.provider; + const options = (args as CacheEnvelope).cache!; + + // TODO: hash + const key = stableStringify({ + ...argsWithoutCache, + options, + model, + operation, + })!; + + const queryResultEntry = await cache.getQueryResult(key); + + if (queryResultEntry) { + return queryResultEntry.result; + } + + const result = await proceed(args); + + cache.setQueryResult(key, { + createdAt: Date.now(), + options, + result, + }).catch(err => console.error(err)); + + return result; + } + + return proceed(args); + }, + }); +} \ No newline at end of file diff --git a/packages/plugins/cache/src/providers/memory.ts b/packages/plugins/cache/src/providers/memory.ts new file mode 100644 index 000000000..77b6d477a --- /dev/null +++ b/packages/plugins/cache/src/providers/memory.ts @@ -0,0 +1,46 @@ +import type { CacheInvalidationOptions, CacheProvider, CacheQueryResultEntry } from '../types'; +import { entryIsExpired } from '../utils'; + +export class MemoryCache implements CacheProvider { + private readonly queryResultStore: Map + + // TODO: tags store + + constructor() { + this.queryResultStore = new Map(); + + setInterval(() => { + this.checkExpiration(); + }, 60000).unref(); + } + + private checkExpiration() { + for (const [key, entry] of this.queryResultStore.entries()) { + if (entryIsExpired(entry)) { + this.delete(key); + } + } + } + + getQueryResult(key: string) { + return Promise.resolve(this.queryResultStore.get(key)); + } + + setQueryResult(key: string, entry: CacheQueryResultEntry) { + this.queryResultStore.set(key, entry); + return Promise.resolve(); + } + + delete(key: string) { + return Promise.resolve(this.queryResultStore.delete(key)); + } + + invalidate(_options: CacheInvalidationOptions) { + return Promise.resolve(); + } + + invalidateAll() { + this.queryResultStore.clear(); + return Promise.resolve(); + } +} \ No newline at end of file diff --git a/packages/plugins/cache/src/schemas.ts b/packages/plugins/cache/src/schemas.ts new file mode 100644 index 000000000..36c3e418a --- /dev/null +++ b/packages/plugins/cache/src/schemas.ts @@ -0,0 +1,10 @@ +import z from 'zod'; + +export const cacheOptionsSchema = z.strictObject({ + ttl: z.number().min(1).optional(), + swr: z.number().min(1).optional(), +}); + +export const cacheEnvelopeSchema = z.object({ + cache: cacheOptionsSchema.optional(), +}); diff --git a/packages/plugins/cache/src/types.ts b/packages/plugins/cache/src/types.ts new file mode 100644 index 000000000..64afb4189 --- /dev/null +++ b/packages/plugins/cache/src/types.ts @@ -0,0 +1,34 @@ +import type { AllReadOperations } from '@zenstackhq/orm'; +import type z from 'zod'; +import type { cacheEnvelopeSchema, cacheOptionsSchema } from './schemas'; + +export type CacheEnvelope = z.infer; +export type CacheOptions = z.infer; + +export interface CacheProvider { + getQueryResult: (key: string) => Promise; + setQueryResult: (key: string, entry: CacheQueryResultEntry) => Promise; + invalidate: (options: CacheInvalidationOptions) => Promise; + invalidateAll: () => Promise; +}; + +export type CacheInvalidationOptions = { + tags?: []; +}; + +export type CachePluginQueryOptions = { + [Op in AllReadOperations]: CacheEnvelope; +}; + +export type CacheEntry = { + createdAt: number; + options: CacheOptions; +} + +export type CacheQueryResultEntry = CacheEntry & { + result: unknown; +}; + +export type CachePluginOptions = { + provider: CacheProvider; +}; diff --git a/packages/plugins/cache/src/utils.ts b/packages/plugins/cache/src/utils.ts new file mode 100644 index 000000000..25900a22b --- /dev/null +++ b/packages/plugins/cache/src/utils.ts @@ -0,0 +1,21 @@ +import type { CacheEntry } from './types'; + +export function getTotalTTL(entry: CacheEntry) { + return (entry.options.ttl ?? 0) + (entry.options.swr ?? 0); +} + +export function entryIsFresh(entry: CacheEntry) { + return entry.options.ttl + ? Date.now() <= (entry.createdAt + (entry.options.ttl ?? 0)) + : false; +} + +export function entryIsStale(entry: CacheEntry) { + return entry.options.swr + ? Date.now() <= getTotalTTL(entry) + : false; +} + +export function entryIsExpired(entry: CacheEntry) { + return Date.now() > getTotalTTL(entry); +} From 2182aa9856c60a84c63bb3280ac8689ecbdd7877 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Tue, 20 Jan 2026 01:07:19 +0000 Subject: [PATCH 03/14] chore: memory provider tests --- tests/e2e/orm/cache/memory.test.ts | 294 +++++++++++++++++++++++++++++ tests/e2e/package.json | 1 + 2 files changed, 295 insertions(+) create mode 100644 tests/e2e/orm/cache/memory.test.ts diff --git a/tests/e2e/orm/cache/memory.test.ts b/tests/e2e/orm/cache/memory.test.ts new file mode 100644 index 000000000..f0d408946 --- /dev/null +++ b/tests/e2e/orm/cache/memory.test.ts @@ -0,0 +1,294 @@ +import { type ClientContract } from '@zenstackhq/orm'; +import { createTestClient } from '@zenstackhq/testtools'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { defineCachePlugin } from '@zenstackhq/plugin-cache'; +import { MemoryCache } from '@zenstackhq/plugin-cache/providers/memory'; +import { schema } from '../schemas/basic'; + +describe('Cache plugin (memory)', () => { + let db: ClientContract; + + beforeEach(async () => { + db = await createTestClient(schema); + vi.useFakeTimers(); + }); + + afterEach(async () => { + vi.useRealTimers(); + await db?.$disconnect(); + }); + + test('respects ttl', async () => { + const extDb = db.$use(defineCachePlugin({ + provider: new MemoryCache(), + })); + + const user = await extDb.user.create({ + data: { + email: 'test@email.com', + }, + }); + + await Promise.all([ + extDb.user.findFirst({ + where: { + id: user.id, + }, + + cache: { + ttl: 60, + }, + }), + + extDb.user.findUnique({ + where: { + id: user.id, + }, + + cache: { + ttl: 60, + }, + }), + + extDb.user.findMany({ + cache: { + ttl: 60, + }, + }), + + extDb.user.findFirstOrThrow({ + where: { + id: user.id, + }, + + cache: { + ttl: 60, + }, + }), + + extDb.user.findUniqueOrThrow({ + where: { + id: user.id, + }, + + cache: { + ttl: 60, + }, + }), + + extDb.user.exists({ + where: { + id: user.id, + }, + + cache: { + ttl: 60, + }, + }), + + extDb.user.count({ + cache: { + ttl: 60, + }, + }), + + // extDb.user.aggregate({ + // where: { + // id: user.id, + // }, + + // cache: { + // ttl: 60, + // }, + // }), + + extDb.user.groupBy({ + by: 'id', + + cache: { + ttl: 60, + }, + }), + ]); + + await Promise.all([ + extDb.user.delete({ + where: { + id: user.id, + }, + }), + + extDb.user.create({ + data: { + email: 'test2@email.com', + }, + }), + + extDb.user.create({ + data: { + email: 'test3@email.com', + }, + }), + ]) + + await expect(extDb.user.findFirst({ + where: { + id: user.id, + }, + + cache: { + ttl: 60, + }, + })).resolves.toMatchObject({ + email: 'test@email.com', + }); + + await expect(extDb.user.findUnique({ + where: { + id: user.id, + }, + + cache: { + ttl: 60, + }, + })).resolves.toMatchObject({ + email: 'test@email.com', + }); + + await expect(extDb.user.findMany({ + cache: { + ttl: 60, + }, + })).resolves.toHaveLength(1); + + await expect(extDb.user.findFirstOrThrow({ + where: { + id: user.id, + }, + + cache: { + ttl: 60, + }, + })).resolves.toMatchObject({ + email: 'test@email.com', + }); + + await expect(extDb.user.findUniqueOrThrow({ + where: { + id: user.id, + }, + + cache: { + ttl: 60, + }, + })).resolves.toMatchObject({ + email: 'test@email.com', + }); + + await expect(extDb.user.exists({ + where: { + id: user.id, + }, + + cache: { + ttl: 60, + }, + })).resolves.toBe(true); + + await expect(extDb.user.count({ + cache: { + ttl: 60, + }, + })).resolves.toBe(1); + + // await expect(extDb.user.aggregate({ + // where: { + // id: user.id, + // }, + + // cache: { + // ttl: 60, + // }, + // })).resolves.toHaveLength(1); + + await expect(extDb.user.groupBy({ + by: 'id', + + cache: { + ttl: 60, + }, + })).resolves.toHaveLength(1); + + vi.advanceTimersByTime(60000); + + await expect(extDb.user.findFirst({ + where: { + id: user.id, + }, + + cache: { + ttl: 60, + }, + })).resolves.toBeNull(); + + await expect(extDb.user.findUnique({ + where: { + id: user.id, + }, + + cache: { + ttl: 60, + }, + })).resolves.toBeNull(); + + await expect(extDb.user.findMany({ + cache: { + ttl: 60, + }, + })).resolves.toHaveLength(2); + + await expect(extDb.user.findFirstOrThrow({ + where: { + id: user.id, + }, + + cache: { + ttl: 60, + }, + })).rejects.toThrow('Record not found'); + + await expect(extDb.user.findUniqueOrThrow({ + where: { + id: user.id, + }, + + cache: { + ttl: 60, + }, + })).rejects.toThrow('Record not found'); + + await expect(extDb.user.exists({ + where: { + id: user.id, + }, + + cache: { + ttl: 60, + }, + })).resolves.toBe(false); + + await expect(extDb.user.count({ + cache: { + ttl: 60, + }, + })).resolves.toBe(2); + + await expect(extDb.user.groupBy({ + by: 'id', + + cache: { + ttl: 60, + }, + })).resolves.toHaveLength(2); + }) +}); \ No newline at end of file diff --git a/tests/e2e/package.json b/tests/e2e/package.json index 5022cf2e8..6df8ad6dd 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -17,6 +17,7 @@ "@zenstackhq/language": "workspace:*", "@zenstackhq/orm": "workspace:*", "@zenstackhq/plugin-policy": "workspace:*", + "@zenstackhq/plugin-cache": "workspace:*", "@zenstackhq/schema": "workspace:*", "@zenstackhq/sdk": "workspace:*", "@zenstackhq/testtools": "workspace:*", From 55d4b7ce64f8f521cb04ea72bdb2c5f50389d6b0 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Tue, 20 Jan 2026 05:46:26 +0000 Subject: [PATCH 04/14] chore: add `invalidateAll` tests --- tests/e2e/orm/cache/memory.test.ts | 190 ++++++++++++++++++++++++++++- 1 file changed, 188 insertions(+), 2 deletions(-) diff --git a/tests/e2e/orm/cache/memory.test.ts b/tests/e2e/orm/cache/memory.test.ts index f0d408946..3a02df907 100644 --- a/tests/e2e/orm/cache/memory.test.ts +++ b/tests/e2e/orm/cache/memory.test.ts @@ -129,7 +129,7 @@ describe('Cache plugin (memory)', () => { email: 'test3@email.com', }, }), - ]) + ]); await expect(extDb.user.findFirst({ where: { @@ -290,5 +290,191 @@ describe('Cache plugin (memory)', () => { ttl: 60, }, })).resolves.toHaveLength(2); - }) + }); + + test('supports invalidating all entries', async () => { + const extDb = db.$use(defineCachePlugin({ + provider: new MemoryCache(), + })); + + const user = await extDb.user.create({ + data: { + email: 'test@email.com', + }, + }); + + await Promise.all([ + extDb.user.findFirst({ + where: { + id: user.id, + }, + + cache: { + ttl: 60, + }, + }), + + extDb.user.findUnique({ + where: { + id: user.id, + }, + + cache: { + ttl: 60, + }, + }), + + extDb.user.findMany({ + cache: { + ttl: 60, + }, + }), + + extDb.user.findFirstOrThrow({ + where: { + id: user.id, + }, + + cache: { + ttl: 60, + }, + }), + + extDb.user.findUniqueOrThrow({ + where: { + id: user.id, + }, + + cache: { + ttl: 60, + }, + }), + + extDb.user.exists({ + where: { + id: user.id, + }, + + cache: { + ttl: 60, + }, + }), + + extDb.user.count({ + cache: { + ttl: 60, + }, + }), + + // extDb.user.aggregate({ + // where: { + // id: user.id, + // }, + + // cache: { + // ttl: 60, + // }, + // }), + + extDb.user.groupBy({ + by: 'id', + + cache: { + ttl: 60, + }, + }), + ]); + + await Promise.all([ + extDb.user.delete({ + where: { + id: user.id, + }, + }), + + extDb.user.create({ + data: { + email: 'test2@email.com', + }, + }), + + extDb.user.create({ + data: { + email: 'test3@email.com', + }, + }), + ]); + + extDb.$cache.invalidateAll(); + + await expect(extDb.user.findFirst({ + where: { + id: user.id, + }, + + cache: { + ttl: 60, + }, + })).resolves.toBeNull(); + + await expect(extDb.user.findUnique({ + where: { + id: user.id, + }, + + cache: { + ttl: 60, + }, + })).resolves.toBeNull(); + + await expect(extDb.user.findMany({ + cache: { + ttl: 60, + }, + })).resolves.toHaveLength(2); + + await expect(extDb.user.findFirstOrThrow({ + where: { + id: user.id, + }, + + cache: { + ttl: 60, + }, + })).rejects.toThrow('Record not found'); + + await expect(extDb.user.findUniqueOrThrow({ + where: { + id: user.id, + }, + + cache: { + ttl: 60, + }, + })).rejects.toThrow('Record not found'); + + await expect(extDb.user.exists({ + where: { + id: user.id, + }, + + cache: { + ttl: 60, + }, + })).resolves.toBe(false); + + await expect(extDb.user.count({ + cache: { + ttl: 60, + }, + })).resolves.toBe(2); + + await expect(extDb.user.groupBy({ + by: 'id', + + cache: { + ttl: 60, + }, + })).resolves.toHaveLength(2); + }); }); \ No newline at end of file From c289d0b335e03bf7d738b5c0875582be8b7e738d Mon Sep 17 00:00:00 2001 From: sanny-io Date: Tue, 20 Jan 2026 05:57:59 +0000 Subject: [PATCH 05/14] feat: configurable `checkInterval` --- packages/plugins/cache/src/plugin.ts | 2 +- packages/plugins/cache/src/providers/memory.ts | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/plugins/cache/src/plugin.ts b/packages/plugins/cache/src/plugin.ts index 427e2867e..e59784081 100644 --- a/packages/plugins/cache/src/plugin.ts +++ b/packages/plugins/cache/src/plugin.ts @@ -58,7 +58,7 @@ export function defineCachePlugin(pluginOptions: CachePluginOptions) { createdAt: Date.now(), options, result, - }).catch(err => console.error(err)); + }).catch((err) => console.error(`Failed to cache query result: ${err}`)); return result; } diff --git a/packages/plugins/cache/src/providers/memory.ts b/packages/plugins/cache/src/providers/memory.ts index 77b6d477a..98c78231c 100644 --- a/packages/plugins/cache/src/providers/memory.ts +++ b/packages/plugins/cache/src/providers/memory.ts @@ -6,12 +6,12 @@ export class MemoryCache implements CacheProvider { // TODO: tags store - constructor() { + constructor(private readonly options?: MemoryCacheOptions) { this.queryResultStore = new Map(); setInterval(() => { this.checkExpiration(); - }, 60000).unref(); + }, this.options?.checkInterval ?? 60000).unref(); } private checkExpiration() { @@ -43,4 +43,8 @@ export class MemoryCache implements CacheProvider { this.queryResultStore.clear(); return Promise.resolve(); } -} \ No newline at end of file +} + +export type MemoryCacheOptions = { + checkInterval?: number; +}; \ No newline at end of file From 6c86f17a6242989b307fa13d382cc914a8f6b598 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Tue, 20 Jan 2026 07:20:56 +0000 Subject: [PATCH 06/14] feat: tag invalidation --- .../plugins/cache/src/providers/memory.ts | 49 +++++++++++++++++-- packages/plugins/cache/src/schemas.ts | 1 + packages/plugins/cache/src/types.ts | 2 +- 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/packages/plugins/cache/src/providers/memory.ts b/packages/plugins/cache/src/providers/memory.ts index 98c78231c..558342dc2 100644 --- a/packages/plugins/cache/src/providers/memory.ts +++ b/packages/plugins/cache/src/providers/memory.ts @@ -2,13 +2,13 @@ import type { CacheInvalidationOptions, CacheProvider, CacheQueryResultEntry } f import { entryIsExpired } from '../utils'; export class MemoryCache implements CacheProvider { - private readonly queryResultStore: Map - - // TODO: tags store + private readonly queryResultStore: Map; + private readonly tagStore: Map>; constructor(private readonly options?: MemoryCacheOptions) { this.queryResultStore = new Map(); - + this.tagStore = new Map>; + setInterval(() => { this.checkExpiration(); }, this.options?.checkInterval ?? 60000).unref(); @@ -20,6 +20,18 @@ export class MemoryCache implements CacheProvider { this.delete(key); } } + + for (const [tag, queryKeys] of this.tagStore.entries()) { + for (const queryKey of queryKeys) { + if (!this.queryResultStore.has(queryKey)) { + queryKeys.delete(queryKey); + } + } + + if (queryKeys.size === 0) { + this.tagStore.delete(tag); + } + } } getQueryResult(key: string) { @@ -28,6 +40,20 @@ export class MemoryCache implements CacheProvider { setQueryResult(key: string, entry: CacheQueryResultEntry) { this.queryResultStore.set(key, entry); + + if (entry.options.tags) { + for (const tag of entry.options.tags) { + let queryKeys = this.tagStore.get(tag); + + if (!queryKeys) { + queryKeys = new Set(); + this.tagStore.set(tag, queryKeys); + } + + queryKeys.add(key); + } + } + return Promise.resolve(); } @@ -35,12 +61,25 @@ export class MemoryCache implements CacheProvider { return Promise.resolve(this.queryResultStore.delete(key)); } - invalidate(_options: CacheInvalidationOptions) { + invalidate(options: CacheInvalidationOptions) { + if (options.tags) { + for (const tag of options.tags) { + const queryKeys = this.tagStore.get(tag); + + if (queryKeys) { + for (const queryKey of queryKeys) { + this.queryResultStore.delete(queryKey); + } + } + } + } + return Promise.resolve(); } invalidateAll() { this.queryResultStore.clear(); + this.tagStore.clear(); return Promise.resolve(); } } diff --git a/packages/plugins/cache/src/schemas.ts b/packages/plugins/cache/src/schemas.ts index 36c3e418a..b82573d15 100644 --- a/packages/plugins/cache/src/schemas.ts +++ b/packages/plugins/cache/src/schemas.ts @@ -3,6 +3,7 @@ import z from 'zod'; export const cacheOptionsSchema = z.strictObject({ ttl: z.number().min(1).optional(), swr: z.number().min(1).optional(), + tags: z.string().array().optional(), }); export const cacheEnvelopeSchema = z.object({ diff --git a/packages/plugins/cache/src/types.ts b/packages/plugins/cache/src/types.ts index 64afb4189..56c619465 100644 --- a/packages/plugins/cache/src/types.ts +++ b/packages/plugins/cache/src/types.ts @@ -13,7 +13,7 @@ export interface CacheProvider { }; export type CacheInvalidationOptions = { - tags?: []; + tags?: string[]; }; export type CachePluginQueryOptions = { From 526fe9067d3cb301d9409068b38e9d8696c5a1d7 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Tue, 20 Jan 2026 07:21:03 +0000 Subject: [PATCH 07/14] chore: tag invalidation tests --- tests/e2e/orm/cache/memory.test.ts | 259 ++++++++++++++++++++++++++++- 1 file changed, 256 insertions(+), 3 deletions(-) diff --git a/tests/e2e/orm/cache/memory.test.ts b/tests/e2e/orm/cache/memory.test.ts index 3a02df907..be954525d 100644 --- a/tests/e2e/orm/cache/memory.test.ts +++ b/tests/e2e/orm/cache/memory.test.ts @@ -1,6 +1,6 @@ import { type ClientContract } from '@zenstackhq/orm'; import { createTestClient } from '@zenstackhq/testtools'; -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { defineCachePlugin } from '@zenstackhq/plugin-cache'; import { MemoryCache } from '@zenstackhq/plugin-cache/providers/memory'; import { schema } from '../schemas/basic'; @@ -18,7 +18,7 @@ describe('Cache plugin (memory)', () => { await db?.$disconnect(); }); - test('respects ttl', async () => { + it('respects ttl', async () => { const extDb = db.$use(defineCachePlugin({ provider: new MemoryCache(), })); @@ -292,7 +292,7 @@ describe('Cache plugin (memory)', () => { })).resolves.toHaveLength(2); }); - test('supports invalidating all entries', async () => { + it('supports invalidating all entries', async () => { const extDb = db.$use(defineCachePlugin({ provider: new MemoryCache(), })); @@ -477,4 +477,257 @@ describe('Cache plugin (memory)', () => { }, })).resolves.toHaveLength(2); }); + + it('supports invalidating by tags', async () => { + const extDb = db.$use(defineCachePlugin({ + provider: new MemoryCache(), + })); + + const user1 = await extDb.user.create({ + data: { + email: 'test@email.com', + }, + }); + + const user2 = await extDb.user.create({ + data: { + email: 'test2@email.com', + }, + }); + + const post1 = await extDb.post.create({ + data: { + title: 'title', + authorId: user1.id, + }, + }); + + const post2 = await extDb.post.create({ + data: { + title: 'title', + authorId: user2.id, + }, + }); + + await Promise.all([ + extDb.user.findUnique({ + where: { + id: user1.id, + }, + + cache: { + ttl: 60, + tags: ['user1'], + }, + }), + + extDb.user.findUnique({ + where: { + id: user2.id, + }, + + cache: { + ttl: 60, + tags: ['user2'], + }, + }), + + extDb.post.findUnique({ + where: { + id: post1.id, + }, + + cache: { + ttl: 60, + tags: ['post', 'user1'], + }, + }), + + extDb.post.findUnique({ + where: { + id: post2.id, + }, + + cache: { + ttl: 60, + }, + }), + ]); + + await Promise.all([ + extDb.user.update({ + data: { + name: 'newname', + }, + + where: { + id: user1.id, + }, + }), + + extDb.user.update({ + data: { + name: 'newname', + }, + + where: { + id: user2.id, + }, + }), + + extDb.post.update({ + data: { + title: 'newtitle', + }, + + where: { + id: post1.id, + }, + }), + ]); + + await extDb.$cache.invalidate({ + tags: [], + }); + + // everything should still be the same as when we started + await expect(extDb.user.findUnique({ + where: { + id: user1.id, + }, + + cache: { + ttl: 60, + tags: ['user1'], + }, + })).resolves.toMatchObject({ + name: null, + }); + + await expect(extDb.user.findUnique({ + where: { + id: user2.id, + }, + + cache: { + ttl: 60, + tags: ['user2'], + }, + })).resolves.toMatchObject({ + name: null, + }); + + await expect(extDb.post.findUnique({ + where: { + id: post1.id, + }, + + cache: { + ttl: 60, + tags: ['post', 'user1'], + }, + })).resolves.toMatchObject({ + title: 'title', + }); + + await extDb.$cache.invalidate({ + tags: ['these', 'tags', 'do', 'not', 'exist'], + }); + + // everything should still be the same as when we started + await expect(extDb.user.findUnique({ + where: { + id: user1.id, + }, + + cache: { + ttl: 60, + tags: ['user1'], + }, + })).resolves.toMatchObject({ + name: null, + }); + + await expect(extDb.user.findUnique({ + where: { + id: user2.id, + }, + + cache: { + ttl: 60, + tags: ['user2'], + }, + })).resolves.toMatchObject({ + name: null, + }); + + await expect(extDb.post.findUnique({ + where: { + id: post1.id, + }, + + cache: { + ttl: 60, + tags: ['post', 'user1'], + }, + })).resolves.toMatchObject({ + title: 'title', + }); + + await extDb.$cache.invalidate({ + tags: ['user1'], + }); + + // only user2 and post2 stays the same + await expect(extDb.user.findUnique({ + where: { + id: user1.id, + }, + + cache: { + ttl: 60, + tags: ['user1'], + }, + })).resolves.toMatchObject({ + name: 'newname', + }); + + await expect(extDb.user.findUnique({ + where: { + id: user2.id, + }, + + cache: { + ttl: 60, + tags: ['user2'], + }, + })).resolves.toMatchObject({ + name: null, + }); + + await expect(extDb.post.findUnique({ + where: { + id: post1.id, + }, + + cache: { + ttl: 60, + tags: ['post', 'user1'], + }, + })).resolves.toMatchObject({ + title: 'newtitle', + }); + + await expect(extDb.post.findUnique({ + where: { + id: post2.id, + }, + + cache: { + ttl: 60, + }, + })).resolves.toMatchObject({ + title: 'title', + }); + }); }); \ No newline at end of file From 2f373446c5bbdaf7d2fffba17e31f6ed1e1143e1 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Tue, 20 Jan 2026 07:40:32 +0000 Subject: [PATCH 08/14] Use seconds for `checkInterval`, add documentation. --- packages/plugins/cache/src/providers/memory.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/plugins/cache/src/providers/memory.ts b/packages/plugins/cache/src/providers/memory.ts index 558342dc2..b810fac7d 100644 --- a/packages/plugins/cache/src/providers/memory.ts +++ b/packages/plugins/cache/src/providers/memory.ts @@ -11,7 +11,7 @@ export class MemoryCache implements CacheProvider { setInterval(() => { this.checkExpiration(); - }, this.options?.checkInterval ?? 60000).unref(); + }, (this.options?.checkInterval ?? 60) * 1000).unref(); } private checkExpiration() { @@ -85,5 +85,10 @@ export class MemoryCache implements CacheProvider { } export type MemoryCacheOptions = { + /** + * How often, in seconds, entries will be checked for expiration. + * + * @default 60 + */ checkInterval?: number; }; \ No newline at end of file From fc2f9eed85f00492b388fc7abbda08fb736ecb28 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Tue, 20 Jan 2026 12:07:02 +0000 Subject: [PATCH 09/14] Add murmurhash. --- packages/plugins/cache/package.json | 5 ++-- packages/plugins/cache/src/plugin.ts | 24 ++++++++----------- .../plugins/cache/src/providers/memory.ts | 6 +---- pnpm-lock.yaml | 14 ++++++++--- 4 files changed, 25 insertions(+), 24 deletions(-) diff --git a/packages/plugins/cache/package.json b/packages/plugins/cache/package.json index d06b26800..6c438c163 100644 --- a/packages/plugins/cache/package.json +++ b/packages/plugins/cache/package.json @@ -44,8 +44,9 @@ "dependencies": { "@zenstackhq/common-helpers": "workspace:*", "@zenstackhq/orm": "workspace:*", - "zod": "catalog:", - "json-stable-stringify": "^1.3.0" + "json-stable-stringify": "^1.3.0", + "murmurhash": "^2.0.1", + "zod": "catalog:" }, "devDependencies": { "@zenstackhq/eslint-config": "workspace:*", diff --git a/packages/plugins/cache/src/plugin.ts b/packages/plugins/cache/src/plugin.ts index e59784081..870e55e1d 100644 --- a/packages/plugins/cache/src/plugin.ts +++ b/packages/plugins/cache/src/plugin.ts @@ -1,5 +1,7 @@ +import { lowerCaseFirst } from '@zenstackhq/common-helpers'; import { definePlugin } from '@zenstackhq/orm'; import stableStringify from 'json-stable-stringify'; +import murmurhash from 'murmurhash'; import { cacheEnvelopeSchema } from './schemas'; import type { CacheEnvelope, CacheInvalidationOptions, CachePluginOptions } from './types'; @@ -27,25 +29,19 @@ export function defineCachePlugin(pluginOptions: CachePluginOptions) { onQuery: async ({ args, model, operation, proceed }) => { if (args && 'cache' in args) { - const argsWithoutCache: Record = {}; + const json = stableStringify({ + args, + model, + operation, + }); - for (const [key, value] of Object.entries(args)) { - if (key !== 'cache') { - argsWithoutCache[key] = value; - } + if (!json) { + throw new Error(`Failed to serialize cache entry for ${lowerCaseFirst(model)}.${operation}`); } const cache = pluginOptions.provider; const options = (args as CacheEnvelope).cache!; - - // TODO: hash - const key = stableStringify({ - ...argsWithoutCache, - options, - model, - operation, - })!; - + const key = murmurhash.v3(json).toString(); const queryResultEntry = await cache.getQueryResult(key); if (queryResultEntry) { diff --git a/packages/plugins/cache/src/providers/memory.ts b/packages/plugins/cache/src/providers/memory.ts index b810fac7d..feee28a28 100644 --- a/packages/plugins/cache/src/providers/memory.ts +++ b/packages/plugins/cache/src/providers/memory.ts @@ -17,7 +17,7 @@ export class MemoryCache implements CacheProvider { private checkExpiration() { for (const [key, entry] of this.queryResultStore.entries()) { if (entryIsExpired(entry)) { - this.delete(key); + this.queryResultStore.delete(key); } } @@ -57,10 +57,6 @@ export class MemoryCache implements CacheProvider { return Promise.resolve(); } - delete(key: string) { - return Promise.resolve(this.queryResultStore.delete(key)); - } - invalidate(options: CacheInvalidationOptions) { if (options.tags) { for (const tag of options.tags) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f793eb53a..011e4c4eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -583,6 +583,9 @@ importers: json-stable-stringify: specifier: ^1.3.0 version: 1.3.0 + murmurhash: + specifier: ^2.0.1 + version: 2.0.1 zod: specifier: 'catalog:' version: 4.1.12 @@ -6307,6 +6310,9 @@ packages: muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + murmurhash@2.0.1: + resolution: {integrity: sha512-5vQEh3y+DG/lMPM0mCGPDnyV8chYg/g7rl6v3Gd8WMF9S429ox3Xk8qrk174kWhG767KQMqqxLD1WnGd77hiew==} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -12524,7 +12530,7 @@ snapshots: eslint: 9.29.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.29.0(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.29.0(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.29.0(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.29.0(jiti@2.6.1)) @@ -12557,7 +12563,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.29.0(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -12572,7 +12578,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.29.0(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -13967,6 +13973,8 @@ snapshots: muggle-string@0.4.1: {} + murmurhash@2.0.1: {} + mz@2.7.0: dependencies: any-promise: 1.3.0 From 6b9cc95f0d192aeed74f1a7160afb0dc3b57e5fa Mon Sep 17 00:00:00 2001 From: sanny-io Date: Tue, 20 Jan 2026 13:05:00 +0000 Subject: [PATCH 10/14] `swr` support --- packages/plugins/cache/src/plugin.ts | 45 +++++++++++++++++- packages/plugins/cache/src/types.ts | 2 + packages/plugins/cache/src/utils.ts | 2 +- tests/e2e/orm/cache/memory.test.ts | 71 ++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+), 3 deletions(-) diff --git a/packages/plugins/cache/src/plugin.ts b/packages/plugins/cache/src/plugin.ts index 870e55e1d..86ed704db 100644 --- a/packages/plugins/cache/src/plugin.ts +++ b/packages/plugins/cache/src/plugin.ts @@ -3,9 +3,13 @@ import { definePlugin } from '@zenstackhq/orm'; import stableStringify from 'json-stable-stringify'; import murmurhash from 'murmurhash'; import { cacheEnvelopeSchema } from './schemas'; -import type { CacheEnvelope, CacheInvalidationOptions, CachePluginOptions } from './types'; +import type { CacheEnvelope, CacheInvalidationOptions, CachePluginOptions, CacheStatus } from './types'; +import { entryIsFresh, entryIsStale } from './utils' export function defineCachePlugin(pluginOptions: CachePluginOptions) { + let status: CacheStatus | null = null; + let revalidation: Promise | null = null; + return definePlugin({ id: 'cache', name: 'Cache', @@ -24,6 +28,23 @@ export function defineCachePlugin(pluginOptions: CachePluginOptions) { invalidateAll() { return pluginOptions.provider.invalidateAll(); }, + + /** + * Returns the status of the last result returned, or `null` + * if a result has yet to be returned. + */ + get status() { + return status; + }, + + /** + * Returns a `Promise` that fulfills when the last stale result + * returned has been revalidated, or `null` if a stale result has + * yet to be returned. + */ + get revalidation() { + return revalidation; + } }, }, @@ -45,7 +66,26 @@ export function defineCachePlugin(pluginOptions: CachePluginOptions) { const queryResultEntry = await cache.getQueryResult(key); if (queryResultEntry) { - return queryResultEntry.result; + if (entryIsFresh(queryResultEntry)) { + status = 'hit'; + return queryResultEntry.result; + } else if (entryIsStale(queryResultEntry)) { + revalidation = proceed(args).then(async (result) => { + try { + await cache.setQueryResult(key, { + createdAt: Date.now(), + options, + result, + }) + } + catch (err) { + console.error(`Failed to cache query result: ${err}`) + } + }); + + status = 'stale'; + return queryResultEntry.result; + } } const result = await proceed(args); @@ -56,6 +96,7 @@ export function defineCachePlugin(pluginOptions: CachePluginOptions) { result, }).catch((err) => console.error(`Failed to cache query result: ${err}`)); + status = 'miss'; return result; } diff --git a/packages/plugins/cache/src/types.ts b/packages/plugins/cache/src/types.ts index 56c619465..1aecd279b 100644 --- a/packages/plugins/cache/src/types.ts +++ b/packages/plugins/cache/src/types.ts @@ -32,3 +32,5 @@ export type CacheQueryResultEntry = CacheEntry & { export type CachePluginOptions = { provider: CacheProvider; }; + +export type CacheStatus = 'hit' | 'miss' | 'stale'; \ No newline at end of file diff --git a/packages/plugins/cache/src/utils.ts b/packages/plugins/cache/src/utils.ts index 25900a22b..e831c0f48 100644 --- a/packages/plugins/cache/src/utils.ts +++ b/packages/plugins/cache/src/utils.ts @@ -12,7 +12,7 @@ export function entryIsFresh(entry: CacheEntry) { export function entryIsStale(entry: CacheEntry) { return entry.options.swr - ? Date.now() <= getTotalTTL(entry) + ? Date.now() <= entry.createdAt + getTotalTTL(entry) : false; } diff --git a/tests/e2e/orm/cache/memory.test.ts b/tests/e2e/orm/cache/memory.test.ts index be954525d..1cc816325 100644 --- a/tests/e2e/orm/cache/memory.test.ts +++ b/tests/e2e/orm/cache/memory.test.ts @@ -292,6 +292,77 @@ describe('Cache plugin (memory)', () => { })).resolves.toHaveLength(2); }); + it('respects swr', async () => { + const extDb = db.$use(defineCachePlugin({ + provider: new MemoryCache(), + })); + + const user = await extDb.user.create({ + data: { + email: 'test@email.com', + }, + }); + + await extDb.user.findFirst({ + where: { + id: user.id, + }, + + cache: { + swr: 60, + }, + }); + + await extDb.user.update({ + data: { + name: 'newname', + }, + + where: { + id: user.id, + }, + }); + + await expect(extDb.user.findFirst({ + where: { + id: user.id, + }, + + cache: { + swr: 60, + }, + })).resolves.toMatchObject({ + name: null, + }); + + await expect(extDb.user.findFirst({ + where: { + id: user.id, + }, + + cache: { + swr: 60, + }, + })).resolves.toMatchObject({ + name: null, + }); + + expect(extDb.$cache.status).toBe('stale'); + await extDb.$cache.revalidation; + + await expect(extDb.user.findFirst({ + where: { + id: user.id, + }, + + cache: { + swr: 60, + }, + })).resolves.toMatchObject({ + name: 'newname', + }); + }); + it('supports invalidating all entries', async () => { const extDb = db.$use(defineCachePlugin({ provider: new MemoryCache(), From 930f89fca2342e5e3b3cad861133a41a4aaab291 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Wed, 21 Jan 2026 02:41:20 +0000 Subject: [PATCH 11/14] `ttl` and `swr` simultaneous tests. --- packages/plugins/cache/src/utils.ts | 8 +-- tests/e2e/orm/cache/memory.test.ts | 93 ++++++++++++++++++++++++++++- 2 files changed, 96 insertions(+), 5 deletions(-) diff --git a/packages/plugins/cache/src/utils.ts b/packages/plugins/cache/src/utils.ts index e831c0f48..d0d9590f7 100644 --- a/packages/plugins/cache/src/utils.ts +++ b/packages/plugins/cache/src/utils.ts @@ -6,16 +6,16 @@ export function getTotalTTL(entry: CacheEntry) { export function entryIsFresh(entry: CacheEntry) { return entry.options.ttl - ? Date.now() <= (entry.createdAt + (entry.options.ttl ?? 0)) - : false; + ? Date.now() <= (entry.createdAt + ((entry.options.ttl ?? 0) * 1000)) + : false } export function entryIsStale(entry: CacheEntry) { return entry.options.swr - ? Date.now() <= entry.createdAt + getTotalTTL(entry) + ? Date.now() <= entry.createdAt + (getTotalTTL(entry) * 1000) : false; } export function entryIsExpired(entry: CacheEntry) { - return Date.now() > getTotalTTL(entry); + return Date.now() > entry.createdAt + (getTotalTTL(entry) * 1000); } diff --git a/tests/e2e/orm/cache/memory.test.ts b/tests/e2e/orm/cache/memory.test.ts index 1cc816325..a4c027ea1 100644 --- a/tests/e2e/orm/cache/memory.test.ts +++ b/tests/e2e/orm/cache/memory.test.ts @@ -219,7 +219,7 @@ describe('Cache plugin (memory)', () => { }, })).resolves.toHaveLength(1); - vi.advanceTimersByTime(60000); + vi.advanceTimersByTime(61000); await expect(extDb.user.findFirst({ where: { @@ -335,12 +335,77 @@ describe('Cache plugin (memory)', () => { name: null, }); + expect(extDb.$cache.status).toBe('stale'); + await extDb.$cache.revalidation; + + await expect(extDb.user.findFirst({ + where: { + id: user.id, + }, + + cache: { + swr: 60, + }, + })).resolves.toMatchObject({ + name: 'newname', + }); + }); + + it('respects ttl and swr simultaneously', async () => { + const extDb = db.$use(defineCachePlugin({ + provider: new MemoryCache(), + })); + + const user = await extDb.user.create({ + data: { + email: 'test@email.com', + }, + }); + + await extDb.user.findFirst({ + where: { + id: user.id, + }, + + cache: { + ttl: 60, + swr: 60, + }, + }); + + await extDb.user.update({ + data: { + name: 'newname', + }, + + where: { + id: user.id, + }, + }); + + await expect(extDb.user.findFirst({ + where: { + id: user.id, + }, + + cache: { + ttl: 60, + swr: 60, + }, + })).resolves.toMatchObject({ + name: null, + }); + + expect(extDb.$cache.status).toBe('hit'); + vi.advanceTimersByTime(65000); + await expect(extDb.user.findFirst({ where: { id: user.id, }, cache: { + ttl: 60, swr: 60, }, })).resolves.toMatchObject({ @@ -356,6 +421,7 @@ describe('Cache plugin (memory)', () => { }, cache: { + ttl: 60, swr: 60, }, })).resolves.toMatchObject({ @@ -801,4 +867,29 @@ describe('Cache plugin (memory)', () => { title: 'title', }); }); + + it('handles edge cases', async () => { + const extDb = db.$use(defineCachePlugin({ + provider: new MemoryCache(), + })); + + await expect(extDb.user.findMany({ + cache: { + ttl: 0, + }, + })).rejects.toThrow('Invalid findMany'); + + await expect(extDb.user.findMany({ + cache: { + swr: 0, + }, + })).rejects.toThrow('Invalid findMany'); + + await expect(extDb.user.findMany({ + cache: { + ttl: 0, + swr: 0, + }, + })).rejects.toThrow('Invalid findMany'); + }); }); \ No newline at end of file From efbf09ebfcdfb8419c3cf013028528d1a1811e80 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Wed, 21 Jan 2026 03:01:43 +0000 Subject: [PATCH 12/14] Cleanup. --- packages/plugins/cache/src/plugin.ts | 15 +++--- .../plugins/cache/src/providers/memory.ts | 50 +++++++++---------- packages/plugins/cache/src/types.ts | 21 ++++---- tests/e2e/orm/cache/memory.test.ts | 14 +++--- 4 files changed, 53 insertions(+), 47 deletions(-) diff --git a/packages/plugins/cache/src/plugin.ts b/packages/plugins/cache/src/plugin.ts index 86ed704db..ef09b8b82 100644 --- a/packages/plugins/cache/src/plugin.ts +++ b/packages/plugins/cache/src/plugin.ts @@ -8,7 +8,7 @@ import { entryIsFresh, entryIsStale } from './utils' export function defineCachePlugin(pluginOptions: CachePluginOptions) { let status: CacheStatus | null = null; - let revalidation: Promise | null = null; + let revalidation: Promise | null = null; return definePlugin({ id: 'cache', @@ -63,7 +63,7 @@ export function defineCachePlugin(pluginOptions: CachePluginOptions) { const cache = pluginOptions.provider; const options = (args as CacheEnvelope).cache!; const key = murmurhash.v3(json).toString(); - const queryResultEntry = await cache.getQueryResult(key); + const queryResultEntry = await cache.get(key); if (queryResultEntry) { if (entryIsFresh(queryResultEntry)) { @@ -72,14 +72,17 @@ export function defineCachePlugin(pluginOptions: CachePluginOptions) { } else if (entryIsStale(queryResultEntry)) { revalidation = proceed(args).then(async (result) => { try { - await cache.setQueryResult(key, { + await cache.set(key, { createdAt: Date.now(), options, result, - }) + }); + + return result; } catch (err) { - console.error(`Failed to cache query result: ${err}`) + console.error(`Failed to cache query result: ${err}`); + return null; } }); @@ -90,7 +93,7 @@ export function defineCachePlugin(pluginOptions: CachePluginOptions) { const result = await proceed(args); - cache.setQueryResult(key, { + cache.set(key, { createdAt: Date.now(), options, result, diff --git a/packages/plugins/cache/src/providers/memory.ts b/packages/plugins/cache/src/providers/memory.ts index feee28a28..e030a6d63 100644 --- a/packages/plugins/cache/src/providers/memory.ts +++ b/packages/plugins/cache/src/providers/memory.ts @@ -1,12 +1,12 @@ -import type { CacheInvalidationOptions, CacheProvider, CacheQueryResultEntry } from '../types'; +import type { CacheInvalidationOptions, CacheProvider, CacheEntry } from '../types'; import { entryIsExpired } from '../utils'; -export class MemoryCache implements CacheProvider { - private readonly queryResultStore: Map; +export class MemoryCacheProvider implements CacheProvider { + private readonly entryStore: Map; private readonly tagStore: Map>; constructor(private readonly options?: MemoryCacheOptions) { - this.queryResultStore = new Map(); + this.entryStore = new Map(); this.tagStore = new Map>; setInterval(() => { @@ -15,42 +15,42 @@ export class MemoryCache implements CacheProvider { } private checkExpiration() { - for (const [key, entry] of this.queryResultStore.entries()) { + for (const [key, entry] of this.entryStore) { if (entryIsExpired(entry)) { - this.queryResultStore.delete(key); + this.entryStore.delete(key); } } - for (const [tag, queryKeys] of this.tagStore.entries()) { - for (const queryKey of queryKeys) { - if (!this.queryResultStore.has(queryKey)) { - queryKeys.delete(queryKey); + for (const [tag, keys] of this.tagStore) { + for (const key of keys) { + if (!this.entryStore.has(key)) { + keys.delete(key); } } - if (queryKeys.size === 0) { + if (keys.size === 0) { this.tagStore.delete(tag); } } } - getQueryResult(key: string) { - return Promise.resolve(this.queryResultStore.get(key)); + get(key: string) { + return Promise.resolve(this.entryStore.get(key)); } - setQueryResult(key: string, entry: CacheQueryResultEntry) { - this.queryResultStore.set(key, entry); + set(key: string, entry: CacheEntry) { + this.entryStore.set(key, entry); if (entry.options.tags) { for (const tag of entry.options.tags) { - let queryKeys = this.tagStore.get(tag); + let keys = this.tagStore.get(tag); - if (!queryKeys) { - queryKeys = new Set(); - this.tagStore.set(tag, queryKeys); + if (!keys) { + keys = new Set(); + this.tagStore.set(tag, keys); } - queryKeys.add(key); + keys.add(key); } } @@ -60,11 +60,11 @@ export class MemoryCache implements CacheProvider { invalidate(options: CacheInvalidationOptions) { if (options.tags) { for (const tag of options.tags) { - const queryKeys = this.tagStore.get(tag); + const keys = this.tagStore.get(tag); - if (queryKeys) { - for (const queryKey of queryKeys) { - this.queryResultStore.delete(queryKey); + if (keys) { + for (const key of keys) { + this.entryStore.delete(key); } } } @@ -74,7 +74,7 @@ export class MemoryCache implements CacheProvider { } invalidateAll() { - this.queryResultStore.clear(); + this.entryStore.clear(); this.tagStore.clear(); return Promise.resolve(); } diff --git a/packages/plugins/cache/src/types.ts b/packages/plugins/cache/src/types.ts index 1aecd279b..ca3e5ad0b 100644 --- a/packages/plugins/cache/src/types.ts +++ b/packages/plugins/cache/src/types.ts @@ -1,4 +1,3 @@ -import type { AllReadOperations } from '@zenstackhq/orm'; import type z from 'zod'; import type { cacheEnvelopeSchema, cacheOptionsSchema } from './schemas'; @@ -6,8 +5,8 @@ export type CacheEnvelope = z.infer; export type CacheOptions = z.infer; export interface CacheProvider { - getQueryResult: (key: string) => Promise; - setQueryResult: (key: string, entry: CacheQueryResultEntry) => Promise; + get: (key: string) => Promise; + set: (key: string, entry: CacheEntry) => Promise; invalidate: (options: CacheInvalidationOptions) => Promise; invalidateAll: () => Promise; }; @@ -16,16 +15,20 @@ export type CacheInvalidationOptions = { tags?: string[]; }; -export type CachePluginQueryOptions = { - [Op in AllReadOperations]: CacheEnvelope; -}; - export type CacheEntry = { + /** + * In unix epoch milliseconds. + */ createdAt: number; + + /** + * The caching options that were passed to the query. + */ options: CacheOptions; -} -export type CacheQueryResultEntry = CacheEntry & { + /** + * The result of executing the query. + */ result: unknown; }; diff --git a/tests/e2e/orm/cache/memory.test.ts b/tests/e2e/orm/cache/memory.test.ts index a4c027ea1..e3bca1cf5 100644 --- a/tests/e2e/orm/cache/memory.test.ts +++ b/tests/e2e/orm/cache/memory.test.ts @@ -2,7 +2,7 @@ import { type ClientContract } from '@zenstackhq/orm'; import { createTestClient } from '@zenstackhq/testtools'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { defineCachePlugin } from '@zenstackhq/plugin-cache'; -import { MemoryCache } from '@zenstackhq/plugin-cache/providers/memory'; +import { MemoryCacheProvider } from '@zenstackhq/plugin-cache/providers/memory'; import { schema } from '../schemas/basic'; describe('Cache plugin (memory)', () => { @@ -20,7 +20,7 @@ describe('Cache plugin (memory)', () => { it('respects ttl', async () => { const extDb = db.$use(defineCachePlugin({ - provider: new MemoryCache(), + provider: new MemoryCacheProvider(), })); const user = await extDb.user.create({ @@ -294,7 +294,7 @@ describe('Cache plugin (memory)', () => { it('respects swr', async () => { const extDb = db.$use(defineCachePlugin({ - provider: new MemoryCache(), + provider: new MemoryCacheProvider(), })); const user = await extDb.user.create({ @@ -353,7 +353,7 @@ describe('Cache plugin (memory)', () => { it('respects ttl and swr simultaneously', async () => { const extDb = db.$use(defineCachePlugin({ - provider: new MemoryCache(), + provider: new MemoryCacheProvider(), })); const user = await extDb.user.create({ @@ -431,7 +431,7 @@ describe('Cache plugin (memory)', () => { it('supports invalidating all entries', async () => { const extDb = db.$use(defineCachePlugin({ - provider: new MemoryCache(), + provider: new MemoryCacheProvider(), })); const user = await extDb.user.create({ @@ -617,7 +617,7 @@ describe('Cache plugin (memory)', () => { it('supports invalidating by tags', async () => { const extDb = db.$use(defineCachePlugin({ - provider: new MemoryCache(), + provider: new MemoryCacheProvider(), })); const user1 = await extDb.user.create({ @@ -870,7 +870,7 @@ describe('Cache plugin (memory)', () => { it('handles edge cases', async () => { const extDb = db.$use(defineCachePlugin({ - provider: new MemoryCache(), + provider: new MemoryCacheProvider(), })); await expect(extDb.user.findMany({ From 10d826702e8799cc8b0dd3591b8d8d868dfca5b9 Mon Sep 17 00:00:00 2001 From: sanny-io Date: Wed, 21 Jan 2026 03:28:14 +0000 Subject: [PATCH 13/14] Add `onIntervalExpiration` and tests. --- .../plugins/cache/src/providers/memory.ts | 6 ++++ tests/e2e/orm/cache/memory.test.ts | 31 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/packages/plugins/cache/src/providers/memory.ts b/packages/plugins/cache/src/providers/memory.ts index e030a6d63..501cc28fd 100644 --- a/packages/plugins/cache/src/providers/memory.ts +++ b/packages/plugins/cache/src/providers/memory.ts @@ -18,6 +18,7 @@ export class MemoryCacheProvider implements CacheProvider { for (const [key, entry] of this.entryStore) { if (entryIsExpired(entry)) { this.entryStore.delete(key); + this.options?.onIntervalExpiration?.(entry); } } @@ -87,4 +88,9 @@ export type MemoryCacheOptions = { * @default 60 */ checkInterval?: number; + + /** + * Called when an entry has expired via the interval check. + */ + onIntervalExpiration?: (entry: CacheEntry) => void, }; \ No newline at end of file diff --git a/tests/e2e/orm/cache/memory.test.ts b/tests/e2e/orm/cache/memory.test.ts index e3bca1cf5..2e65db25d 100644 --- a/tests/e2e/orm/cache/memory.test.ts +++ b/tests/e2e/orm/cache/memory.test.ts @@ -868,6 +868,37 @@ describe('Cache plugin (memory)', () => { }); }); + it('supports custom options', async () => { + const onIntervalExpiration = vi.fn(() => {}); + const extDb = db.$use(defineCachePlugin({ + provider: new MemoryCacheProvider({ + checkInterval: 10, + onIntervalExpiration, + }), + })); + + await extDb.user.exists({ + cache: { + ttl: 5, + }, + }); + + vi.advanceTimersByTime(5100); + expect(onIntervalExpiration).not.toHaveBeenCalled(); + vi.advanceTimersByTime(10000); + expect(onIntervalExpiration).toHaveBeenCalledOnce(); + + // @ts-expect-error + const arg = onIntervalExpiration.mock.lastCall[0]; + + expect(arg).toMatchObject({ + result: false, + options: { + ttl: 5, + }, + }) + }); + it('handles edge cases', async () => { const extDb = db.$use(defineCachePlugin({ provider: new MemoryCacheProvider(), From 6bd35e949c475f7237c22ccf99b497830134e0fe Mon Sep 17 00:00:00 2001 From: sanny-io Date: Wed, 21 Jan 2026 03:57:09 +0000 Subject: [PATCH 14/14] Add `status` and `revalidation` checks. --- tests/e2e/orm/cache/memory.test.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/e2e/orm/cache/memory.test.ts b/tests/e2e/orm/cache/memory.test.ts index 2e65db25d..cde7a65db 100644 --- a/tests/e2e/orm/cache/memory.test.ts +++ b/tests/e2e/orm/cache/memory.test.ts @@ -23,6 +23,9 @@ describe('Cache plugin (memory)', () => { provider: new MemoryCacheProvider(), })); + expect(extDb.$cache.status).toBe(null); + expect(extDb.$cache.revalidation).toBe(null); + const user = await extDb.user.create({ data: { email: 'test@email.com', @@ -111,6 +114,8 @@ describe('Cache plugin (memory)', () => { }), ]); + expect(extDb.$cache.status).toBe('miss'); + await Promise.all([ extDb.user.delete({ where: { @@ -143,6 +148,8 @@ describe('Cache plugin (memory)', () => { email: 'test@email.com', }); + expect(extDb.$cache.status).toBe('hit'); + await expect(extDb.user.findUnique({ where: { id: user.id, @@ -336,7 +343,11 @@ describe('Cache plugin (memory)', () => { }); expect(extDb.$cache.status).toBe('stale'); - await extDb.$cache.revalidation; + const revalidatedUser = await extDb.$cache.revalidation; + + expect(revalidatedUser).toMatchObject({ + name: 'newname', + }); await expect(extDb.user.findFirst({ where: { @@ -413,6 +424,7 @@ describe('Cache plugin (memory)', () => { }); expect(extDb.$cache.status).toBe('stale'); + expect(extDb.$cache.revalidation).not.toBe(null); await extDb.$cache.revalidation; await expect(extDb.user.findFirst({