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..6c438c163 --- /dev/null +++ b/packages/plugins/cache/package.json @@ -0,0 +1,56 @@ +{ + "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:*", + "json-stable-stringify": "^1.3.0", + "murmurhash": "^2.0.1", + "zod": "catalog:" + }, + "devDependencies": { + "@zenstackhq/eslint-config": "workspace:*", + "@zenstackhq/typescript-config": "workspace:*", + "@zenstackhq/vitest-config": "workspace:*" + } +} 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..ef09b8b82 --- /dev/null +++ b/packages/plugins/cache/src/plugin.ts @@ -0,0 +1,109 @@ +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, 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', + description: 'Optionally caches read queries.', + + queryArgs: { + $read: cacheEnvelopeSchema, + }, + + client: { + $cache: { + invalidate: (options: CacheInvalidationOptions) => { + return pluginOptions.provider.invalidate(options); + }, + + 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; + } + }, + }, + + onQuery: async ({ args, model, operation, proceed }) => { + if (args && 'cache' in args) { + const json = stableStringify({ + args, + model, + operation, + }); + + if (!json) { + throw new Error(`Failed to serialize cache entry for ${lowerCaseFirst(model)}.${operation}`); + } + + const cache = pluginOptions.provider; + const options = (args as CacheEnvelope).cache!; + const key = murmurhash.v3(json).toString(); + const queryResultEntry = await cache.get(key); + + if (queryResultEntry) { + if (entryIsFresh(queryResultEntry)) { + status = 'hit'; + return queryResultEntry.result; + } else if (entryIsStale(queryResultEntry)) { + revalidation = proceed(args).then(async (result) => { + try { + await cache.set(key, { + createdAt: Date.now(), + options, + result, + }); + + return result; + } + catch (err) { + console.error(`Failed to cache query result: ${err}`); + return null; + } + }); + + status = 'stale'; + return queryResultEntry.result; + } + } + + const result = await proceed(args); + + cache.set(key, { + createdAt: Date.now(), + options, + result, + }).catch((err) => console.error(`Failed to cache query result: ${err}`)); + + status = 'miss'; + 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..501cc28fd --- /dev/null +++ b/packages/plugins/cache/src/providers/memory.ts @@ -0,0 +1,96 @@ +import type { CacheInvalidationOptions, CacheProvider, CacheEntry } from '../types'; +import { entryIsExpired } from '../utils'; + +export class MemoryCacheProvider implements CacheProvider { + private readonly entryStore: Map; + private readonly tagStore: Map>; + + constructor(private readonly options?: MemoryCacheOptions) { + this.entryStore = new Map(); + this.tagStore = new Map>; + + setInterval(() => { + this.checkExpiration(); + }, (this.options?.checkInterval ?? 60) * 1000).unref(); + } + + private checkExpiration() { + for (const [key, entry] of this.entryStore) { + if (entryIsExpired(entry)) { + this.entryStore.delete(key); + this.options?.onIntervalExpiration?.(entry); + } + } + + for (const [tag, keys] of this.tagStore) { + for (const key of keys) { + if (!this.entryStore.has(key)) { + keys.delete(key); + } + } + + if (keys.size === 0) { + this.tagStore.delete(tag); + } + } + } + + get(key: string) { + return Promise.resolve(this.entryStore.get(key)); + } + + set(key: string, entry: CacheEntry) { + this.entryStore.set(key, entry); + + if (entry.options.tags) { + for (const tag of entry.options.tags) { + let keys = this.tagStore.get(tag); + + if (!keys) { + keys = new Set(); + this.tagStore.set(tag, keys); + } + + keys.add(key); + } + } + + return Promise.resolve(); + } + + invalidate(options: CacheInvalidationOptions) { + if (options.tags) { + for (const tag of options.tags) { + const keys = this.tagStore.get(tag); + + if (keys) { + for (const key of keys) { + this.entryStore.delete(key); + } + } + } + } + + return Promise.resolve(); + } + + invalidateAll() { + this.entryStore.clear(); + this.tagStore.clear(); + return Promise.resolve(); + } +} + +export type MemoryCacheOptions = { + /** + * How often, in seconds, entries will be checked for expiration. + * + * @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/packages/plugins/cache/src/schemas.ts b/packages/plugins/cache/src/schemas.ts new file mode 100644 index 000000000..b82573d15 --- /dev/null +++ b/packages/plugins/cache/src/schemas.ts @@ -0,0 +1,11 @@ +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({ + 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..ca3e5ad0b --- /dev/null +++ b/packages/plugins/cache/src/types.ts @@ -0,0 +1,39 @@ +import type z from 'zod'; +import type { cacheEnvelopeSchema, cacheOptionsSchema } from './schemas'; + +export type CacheEnvelope = z.infer; +export type CacheOptions = z.infer; + +export interface CacheProvider { + get: (key: string) => Promise; + set: (key: string, entry: CacheEntry) => Promise; + invalidate: (options: CacheInvalidationOptions) => Promise; + invalidateAll: () => Promise; +}; + +export type CacheInvalidationOptions = { + tags?: string[]; +}; + +export type CacheEntry = { + /** + * In unix epoch milliseconds. + */ + createdAt: number; + + /** + * The caching options that were passed to the query. + */ + options: CacheOptions; + + /** + * The result of executing the query. + */ + result: unknown; +}; + +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 new file mode 100644 index 000000000..d0d9590f7 --- /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) * 1000)) + : false +} + +export function entryIsStale(entry: CacheEntry) { + return entry.options.swr + ? Date.now() <= entry.createdAt + (getTotalTTL(entry) * 1000) + : false; +} + +export function entryIsExpired(entry: CacheEntry) { + return Date.now() > entry.createdAt + (getTotalTTL(entry) * 1000); +} 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..011e4c4eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -572,6 +572,34 @@ 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 + murmurhash: + specifier: ^2.0.1 + version: 2.0.1 + 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 +1062,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 @@ -6279,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==} @@ -8605,7 +8639,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 +8778,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 +10918,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 +10928,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 +10937,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 +10964,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 +10976,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 +10993,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 +11009,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 +11434,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.1 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -12496,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)) @@ -12521,7 +12555,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 @@ -12529,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 @@ -12544,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 @@ -13939,6 +13973,8 @@ snapshots: muggle-string@0.4.1: {} + murmurhash@2.0.1: {} + mz@2.7.0: dependencies: any-promise: 1.3.0 diff --git a/tests/e2e/orm/cache/memory.test.ts b/tests/e2e/orm/cache/memory.test.ts new file mode 100644 index 000000000..cde7a65db --- /dev/null +++ b/tests/e2e/orm/cache/memory.test.ts @@ -0,0 +1,938 @@ +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 { MemoryCacheProvider } 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(); + }); + + it('respects ttl', async () => { + const extDb = db.$use(defineCachePlugin({ + 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', + }, + }); + + 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, + }, + }), + ]); + + expect(extDb.$cache.status).toBe('miss'); + + 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', + }); + + expect(extDb.$cache.status).toBe('hit'); + + 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(61000); + + 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); + }); + + it('respects swr', async () => { + const extDb = db.$use(defineCachePlugin({ + provider: new MemoryCacheProvider(), + })); + + 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, + }); + + expect(extDb.$cache.status).toBe('stale'); + const revalidatedUser = await extDb.$cache.revalidation; + + expect(revalidatedUser).toMatchObject({ + name: 'newname', + }); + + 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 MemoryCacheProvider(), + })); + + 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({ + name: null, + }); + + expect(extDb.$cache.status).toBe('stale'); + expect(extDb.$cache.revalidation).not.toBe(null); + await extDb.$cache.revalidation; + + await expect(extDb.user.findFirst({ + where: { + id: user.id, + }, + + cache: { + ttl: 60, + swr: 60, + }, + })).resolves.toMatchObject({ + name: 'newname', + }); + }); + + it('supports invalidating all entries', async () => { + const extDb = db.$use(defineCachePlugin({ + provider: new MemoryCacheProvider(), + })); + + 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); + }); + + it('supports invalidating by tags', async () => { + const extDb = db.$use(defineCachePlugin({ + provider: new MemoryCacheProvider(), + })); + + 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', + }); + }); + + 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(), + })); + + 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 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:*",