Skip to content
This repository was archived by the owner on Mar 1, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/plugins/cache/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import config from '@zenstackhq/eslint-config/base.js';

/** @type {import("eslint").Linter.Config} */
export default config;
56 changes: 56 additions & 0 deletions packages/plugins/cache/package.json
Original file line number Diff line number Diff line change
@@ -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:*"
}
}
1 change: 1 addition & 0 deletions packages/plugins/cache/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './plugin';
109 changes: 109 additions & 0 deletions packages/plugins/cache/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -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<unknown> | 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({
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cache options is now part of the cache key. I guess it's fine, just wanted to make sure it's intentional.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cache options is now part of the cache key. I guess it's fine, just wanted to make sure it's intentional.

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);
},
});
}
96 changes: 96 additions & 0 deletions packages/plugins/cache/src/providers/memory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import type { CacheInvalidationOptions, CacheProvider, CacheEntry } from '../types';
import { entryIsExpired } from '../utils';

export class MemoryCacheProvider implements CacheProvider {
private readonly entryStore: Map<string, CacheEntry>;
private readonly tagStore: Map<string, Set<string>>;

constructor(private readonly options?: MemoryCacheOptions) {
this.entryStore = new Map<string, CacheEntry>();
this.tagStore = new Map<string, Set<string>>;

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<string>();
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,
};
11 changes: 11 additions & 0 deletions packages/plugins/cache/src/schemas.ts
Original file line number Diff line number Diff line change
@@ -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(),
});
39 changes: 39 additions & 0 deletions packages/plugins/cache/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type z from 'zod';
import type { cacheEnvelopeSchema, cacheOptionsSchema } from './schemas';

export type CacheEnvelope = z.infer<typeof cacheEnvelopeSchema>;
export type CacheOptions = z.infer<typeof cacheOptionsSchema>;

export interface CacheProvider {
get: (key: string) => Promise<CacheEntry | undefined>;
set: (key: string, entry: CacheEntry) => Promise<void>;
invalidate: (options: CacheInvalidationOptions) => Promise<void>;
invalidateAll: () => Promise<void>;
};

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';
21 changes: 21 additions & 0 deletions packages/plugins/cache/src/utils.ts
Original file line number Diff line number Diff line change
@@ -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);
}
4 changes: 4 additions & 0 deletions packages/plugins/cache/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "@zenstackhq/typescript-config/base.json",
"include": ["src/**/*"]
}
13 changes: 13 additions & 0 deletions packages/plugins/cache/tsup.config.ts
Original file line number Diff line number Diff line change
@@ -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'],
});
4 changes: 4 additions & 0 deletions packages/plugins/cache/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import base from '@zenstackhq/vitest-config/base';
import { defineConfig, mergeConfig } from 'vitest/config';

export default mergeConfig(base, defineConfig({}));
Loading
Loading