@visualbravo/zenstack-cache 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Visual Bravo LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,136 @@
1
+ <div align="center">
2
+ <h1>
3
+ ZenStack Cache
4
+ <small>(beta)</small>
5
+ </h1>
6
+
7
+ Reduce response times and database load with query-level caching integrated with the ZenStack ORM.
8
+ </div>
9
+
10
+ <div align="center">
11
+ <a href="https://www.npmjs.com/package/@visualbravo/zenstack-cache?activeTab=versions">
12
+ <img alt="NPM Version" src="https://img.shields.io/npm/v/%40visualbravo%2Fzenstack-cache/latest">
13
+ </a>
14
+ <a>
15
+ <img alt="GitHub Actions Workflow Status" src="https://img.shields.io/github/actions/workflow/status/visualbravo/zenstack-cache/build-and-test.yaml">
16
+ </a>
17
+ <a href="https://discord.gg/Ykhr738dUe">
18
+ <img alt="Join the ZenStack server" src="https://img.shields.io/discord/1035538056146595961">
19
+ </a>
20
+ <a href="https://github.com/visualbravo/zenstack-cache/blob/main/LICENSE">
21
+ <img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-green">
22
+ </a>
23
+
24
+ <p>
25
+ ℹ️ This project is not affiliated with or endorsed by the ZenStack team.
26
+ </p>
27
+ </div>
28
+
29
+ ## Features
30
+ * 🌐 **Redis Caching:** Centralizes your caching to scale across different systems.
31
+ * 🖥️ **Memory Caching:** Simplifies caching when scale is not a concern.
32
+ * 🛟 **Type-safe:** The caching options appear in the intellisense for all read queries.
33
+
34
+ ## Requirements
35
+
36
+ * ZenStack (version >= `canary`)
37
+ * Node.js (version >= `20.0.0`)
38
+ * Redis (version >= `7.0.0`)
39
+ * ℹ️ Only if you intend to use the `RedisCacheProvider`
40
+
41
+ ## Installation
42
+
43
+ ```bash
44
+ npm install @visualbravo/zenstack-cache
45
+ pnpm add @visualbravo/zenstack-cache
46
+ bun add @visualbravo/zenstack-cache
47
+ ```
48
+
49
+ ## Sample Usage
50
+
51
+ ```typescript
52
+ import { schema } from './zenstack/schema'
53
+ import { ZenStackClient } from '@zenstackhq/orm'
54
+ import { defineCachePlugin } from '@visualbravo/zenstack-cache'
55
+ import { RedisCacheProvider } from '@visualbravo/zenstack-cache/providers/redis'
56
+ import { MemoryCacheProvider } from '@visualbravo/zenstack-cache/providers/memory'
57
+
58
+ const client = new ZenStackClient(schema, {
59
+ dialect: ...,
60
+ }).$use(
61
+ defineCachePlugin({
62
+ // Choose only one provider.
63
+
64
+ // 1️⃣
65
+ provider: new RedisCacheProvider({
66
+ url: process.env['REDIS_URL'],
67
+ }),
68
+
69
+ // 2️⃣
70
+ provider: new MemoryCacheProvider(),
71
+ }),
72
+ )
73
+
74
+ async function getPostsPublishedByUser(userId: string) {
75
+ const publishedPosts = await client.post.findMany({
76
+ where: {
77
+ published: true,
78
+ authorId: userId,
79
+ },
80
+
81
+ // All of these are optional.
82
+ cache: {
83
+ ttl: 60,
84
+ swr: 120,
85
+ tags: [`user:${userId}`],
86
+ },
87
+ })
88
+
89
+ return publishedPosts
90
+ }
91
+ ```
92
+
93
+ ## Invalidation
94
+
95
+ You can easily invalidate multiple cache entries.
96
+
97
+ ```typescript
98
+ // Invalidate specific tags.
99
+ await client.$cache.invalidate({
100
+ tags: ['user:1'],
101
+ })
102
+
103
+ // Invalidate everything.
104
+ await client.$cache.invalidateAll()
105
+ ```
106
+
107
+ ## Cache Status
108
+
109
+ After performing a query, you can check where the result came from.
110
+
111
+ ```typescript
112
+ const publishedPostsStatus = client.$cache.status // 'hit' | 'miss' | 'stale'
113
+ ```
114
+
115
+ * `hit` - a cache entry in the `ttl` window was found, and the database was not queried.
116
+ * `miss` - a cache entry was not found, and the database was queried.
117
+ * `stale` - a cache entry in the `swr` window was found, and the database was queried in the background to revalidate it.
118
+
119
+ ## Revalidation
120
+
121
+ If the result was stale, you can choose to await its revalidation.
122
+ ```typescript
123
+ const revalidatedPublishedPosts = await client.$cache.revalidation as Post[]
124
+ ```
125
+
126
+ ## Cache Options
127
+
128
+ * `ttl` reduces response times and database load by serving cached results.
129
+ * `swr` reduces response times by serving cached results, but does not reduce database load because it performs a revalidation in the background after each request.
130
+
131
+ > [!NOTE]
132
+ > The total TTL of a cache entry is equal to its `ttl` + `swr`. The `ttl` window comes first, followed by the `swr` window. You can combine the two options to best suit the needs of your application.
133
+
134
+ ## License
135
+
136
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,3 @@
1
+ const require_plugin = require('./plugin.cjs');
2
+
3
+ exports.defineCachePlugin = require_plugin.defineCachePlugin;
@@ -0,0 +1,2 @@
1
+ import { defineCachePlugin } from "./plugin.cjs";
2
+ export { defineCachePlugin };
@@ -0,0 +1,2 @@
1
+ import { defineCachePlugin } from "./plugin.mjs";
2
+ export { defineCachePlugin };
package/dist/index.mjs ADDED
@@ -0,0 +1,3 @@
1
+ import { defineCachePlugin } from "./plugin.mjs";
2
+
3
+ export { defineCachePlugin };
@@ -0,0 +1,84 @@
1
+ const require_rolldown_runtime = require('./_virtual/rolldown_runtime.cjs');
2
+ const require_schemas = require('./schemas.cjs');
3
+ const require_utils = require('./utils.cjs');
4
+ let _zenstackhq_orm = require("@zenstackhq/orm");
5
+ let stable_hash = require("stable-hash");
6
+ let murmurhash = require("murmurhash");
7
+ murmurhash = require_rolldown_runtime.__toESM(murmurhash);
8
+
9
+ //#region src/plugin.ts
10
+ function lowerCaseFirst(input) {
11
+ return input.charAt(0).toLowerCase() + input.slice(1);
12
+ }
13
+ function defineCachePlugin(pluginOptions) {
14
+ let status = null;
15
+ let revalidation = null;
16
+ return (0, _zenstackhq_orm.definePlugin)({
17
+ id: "cache",
18
+ name: "Cache",
19
+ description: "Optionally caches read queries.",
20
+ queryArgs: { $read: require_schemas.cacheEnvelopeSchema },
21
+ client: { $cache: {
22
+ invalidate: (options) => {
23
+ return pluginOptions.provider.invalidate(options);
24
+ },
25
+ invalidateAll() {
26
+ return pluginOptions.provider.invalidateAll();
27
+ },
28
+ get status() {
29
+ return status;
30
+ },
31
+ get revalidation() {
32
+ return revalidation;
33
+ }
34
+ } },
35
+ onQuery: async ({ args, model, operation, proceed }) => {
36
+ if (args && "cache" in args) {
37
+ const json = (0, stable_hash.stableHash)({
38
+ args,
39
+ model,
40
+ operation
41
+ });
42
+ if (!json) throw new Error(`Failed to serialize cache entry for ${lowerCaseFirst(model)}.${operation}`);
43
+ const cache = pluginOptions.provider;
44
+ const options = args.cache;
45
+ const key = murmurhash.default.v3(json).toString();
46
+ const entry = await cache.get(key);
47
+ if (entry) {
48
+ if (require_utils.entryIsFresh(entry)) {
49
+ status = "hit";
50
+ return entry.result;
51
+ } else if (require_utils.entryIsStale(entry)) {
52
+ revalidation = proceed(args).then(async (result$1) => {
53
+ try {
54
+ await cache.set(key, {
55
+ createdAt: Date.now(),
56
+ options,
57
+ result: result$1
58
+ });
59
+ return result$1;
60
+ } catch (err) {
61
+ console.error(`Failed to cache query result: ${err}`);
62
+ return null;
63
+ }
64
+ });
65
+ status = "stale";
66
+ return entry.result;
67
+ }
68
+ }
69
+ const result = await proceed(args);
70
+ cache.set(key, {
71
+ createdAt: Date.now(),
72
+ options,
73
+ result
74
+ }).catch((err) => console.error(`Failed to cache query result: ${err}`));
75
+ status = "miss";
76
+ return result;
77
+ }
78
+ return proceed(args);
79
+ }
80
+ });
81
+ }
82
+
83
+ //#endregion
84
+ exports.defineCachePlugin = defineCachePlugin;
@@ -0,0 +1,31 @@
1
+ import { CacheInvalidationOptions, CachePluginOptions, CacheStatus } from "./types.cjs";
2
+ import * as node_modules__zenstackhq_orm_dist0 from "node_modules/@zenstackhq/orm/dist";
3
+
4
+ //#region src/plugin.d.ts
5
+ declare function defineCachePlugin(pluginOptions: CachePluginOptions): node_modules__zenstackhq_orm_dist0.RuntimePlugin<any, {
6
+ readonly $read: {
7
+ cache?: {
8
+ ttl?: number | undefined;
9
+ swr?: number | undefined;
10
+ tags?: string[] | undefined;
11
+ } | undefined;
12
+ };
13
+ }, {
14
+ readonly $cache: {
15
+ readonly invalidate: (options: CacheInvalidationOptions) => Promise<void>;
16
+ readonly invalidateAll: () => Promise<void>;
17
+ /**
18
+ * Returns the status of the last result returned, or `null`
19
+ * if a result has yet to be returned.
20
+ */
21
+ readonly status: CacheStatus | null;
22
+ /**
23
+ * Returns a `Promise` that fulfills when the last stale result
24
+ * returned has been revalidated, or `null` if a stale result has
25
+ * yet to be returned.
26
+ */
27
+ readonly revalidation: Promise<unknown> | null;
28
+ };
29
+ }>;
30
+ //#endregion
31
+ export { defineCachePlugin };
@@ -0,0 +1,31 @@
1
+ import { CacheInvalidationOptions, CachePluginOptions, CacheStatus } from "./types.mjs";
2
+ import * as node_modules__zenstackhq_orm_dist0 from "node_modules/@zenstackhq/orm/dist";
3
+
4
+ //#region src/plugin.d.ts
5
+ declare function defineCachePlugin(pluginOptions: CachePluginOptions): node_modules__zenstackhq_orm_dist0.RuntimePlugin<any, {
6
+ readonly $read: {
7
+ cache?: {
8
+ ttl?: number | undefined;
9
+ swr?: number | undefined;
10
+ tags?: string[] | undefined;
11
+ } | undefined;
12
+ };
13
+ }, {
14
+ readonly $cache: {
15
+ readonly invalidate: (options: CacheInvalidationOptions) => Promise<void>;
16
+ readonly invalidateAll: () => Promise<void>;
17
+ /**
18
+ * Returns the status of the last result returned, or `null`
19
+ * if a result has yet to be returned.
20
+ */
21
+ readonly status: CacheStatus | null;
22
+ /**
23
+ * Returns a `Promise` that fulfills when the last stale result
24
+ * returned has been revalidated, or `null` if a stale result has
25
+ * yet to be returned.
26
+ */
27
+ readonly revalidation: Promise<unknown> | null;
28
+ };
29
+ }>;
30
+ //#endregion
31
+ export { defineCachePlugin };
@@ -0,0 +1,82 @@
1
+ import { cacheEnvelopeSchema } from "./schemas.mjs";
2
+ import { entryIsFresh, entryIsStale } from "./utils.mjs";
3
+ import { definePlugin } from "@zenstackhq/orm";
4
+ import { stableHash } from "stable-hash";
5
+ import murmurhash from "murmurhash";
6
+
7
+ //#region src/plugin.ts
8
+ function lowerCaseFirst(input) {
9
+ return input.charAt(0).toLowerCase() + input.slice(1);
10
+ }
11
+ function defineCachePlugin(pluginOptions) {
12
+ let status = null;
13
+ let revalidation = null;
14
+ return definePlugin({
15
+ id: "cache",
16
+ name: "Cache",
17
+ description: "Optionally caches read queries.",
18
+ queryArgs: { $read: cacheEnvelopeSchema },
19
+ client: { $cache: {
20
+ invalidate: (options) => {
21
+ return pluginOptions.provider.invalidate(options);
22
+ },
23
+ invalidateAll() {
24
+ return pluginOptions.provider.invalidateAll();
25
+ },
26
+ get status() {
27
+ return status;
28
+ },
29
+ get revalidation() {
30
+ return revalidation;
31
+ }
32
+ } },
33
+ onQuery: async ({ args, model, operation, proceed }) => {
34
+ if (args && "cache" in args) {
35
+ const json = stableHash({
36
+ args,
37
+ model,
38
+ operation
39
+ });
40
+ if (!json) throw new Error(`Failed to serialize cache entry for ${lowerCaseFirst(model)}.${operation}`);
41
+ const cache = pluginOptions.provider;
42
+ const options = args.cache;
43
+ const key = murmurhash.v3(json).toString();
44
+ const entry = await cache.get(key);
45
+ if (entry) {
46
+ if (entryIsFresh(entry)) {
47
+ status = "hit";
48
+ return entry.result;
49
+ } else if (entryIsStale(entry)) {
50
+ revalidation = proceed(args).then(async (result$1) => {
51
+ try {
52
+ await cache.set(key, {
53
+ createdAt: Date.now(),
54
+ options,
55
+ result: result$1
56
+ });
57
+ return result$1;
58
+ } catch (err) {
59
+ console.error(`Failed to cache query result: ${err}`);
60
+ return null;
61
+ }
62
+ });
63
+ status = "stale";
64
+ return entry.result;
65
+ }
66
+ }
67
+ const result = await proceed(args);
68
+ cache.set(key, {
69
+ createdAt: Date.now(),
70
+ options,
71
+ result
72
+ }).catch((err) => console.error(`Failed to cache query result: ${err}`));
73
+ status = "miss";
74
+ return result;
75
+ }
76
+ return proceed(args);
77
+ }
78
+ });
79
+ }
80
+
81
+ //#endregion
82
+ export { defineCachePlugin };
@@ -0,0 +1,55 @@
1
+ const require_utils = require('../utils.cjs');
2
+
3
+ //#region src/providers/memory.ts
4
+ var MemoryCacheProvider = class {
5
+ entryStore;
6
+ tagStore;
7
+ constructor(options) {
8
+ this.options = options;
9
+ this.entryStore = /* @__PURE__ */ new Map();
10
+ this.tagStore = /* @__PURE__ */ new Map();
11
+ setInterval(() => {
12
+ this.checkExpiration();
13
+ }, (this.options?.checkInterval ?? 60) * 1e3).unref();
14
+ }
15
+ checkExpiration() {
16
+ for (const [key, entry] of this.entryStore) if (require_utils.entryIsExpired(entry)) {
17
+ this.entryStore.delete(key);
18
+ this.options?.onIntervalExpiration?.(entry);
19
+ }
20
+ for (const [tag, keys] of this.tagStore) {
21
+ for (const key of keys) if (!this.entryStore.has(key)) keys.delete(key);
22
+ if (keys.size === 0) this.tagStore.delete(tag);
23
+ }
24
+ }
25
+ get(key) {
26
+ return Promise.resolve(this.entryStore.get(key));
27
+ }
28
+ set(key, entry) {
29
+ this.entryStore.set(key, entry);
30
+ if (entry.options.tags) for (const tag of entry.options.tags) {
31
+ let keys = this.tagStore.get(tag);
32
+ if (!keys) {
33
+ keys = /* @__PURE__ */ new Set();
34
+ this.tagStore.set(tag, keys);
35
+ }
36
+ keys.add(key);
37
+ }
38
+ return Promise.resolve();
39
+ }
40
+ invalidate(options) {
41
+ if (options.tags) for (const tag of options.tags) {
42
+ const keys = this.tagStore.get(tag);
43
+ if (keys) for (const key of keys) this.entryStore.delete(key);
44
+ }
45
+ return Promise.resolve();
46
+ }
47
+ invalidateAll() {
48
+ this.entryStore.clear();
49
+ this.tagStore.clear();
50
+ return Promise.resolve();
51
+ }
52
+ };
53
+
54
+ //#endregion
55
+ exports.MemoryCacheProvider = MemoryCacheProvider;
@@ -0,0 +1,28 @@
1
+ import { CacheEntry, CacheInvalidationOptions, CacheProvider } from "../types.cjs";
2
+
3
+ //#region src/providers/memory.d.ts
4
+ declare class MemoryCacheProvider implements CacheProvider {
5
+ private readonly options?;
6
+ private readonly entryStore;
7
+ private readonly tagStore;
8
+ constructor(options?: MemoryCacheOptions | undefined);
9
+ private checkExpiration;
10
+ get(key: string): Promise<CacheEntry | undefined>;
11
+ set(key: string, entry: CacheEntry): Promise<void>;
12
+ invalidate(options: CacheInvalidationOptions): Promise<void>;
13
+ invalidateAll(): Promise<void>;
14
+ }
15
+ type MemoryCacheOptions = {
16
+ /**
17
+ * How often, in seconds, entries will be checked for expiration.
18
+ *
19
+ * @default 60
20
+ */
21
+ checkInterval?: number;
22
+ /**
23
+ * Called when an entry has expired via the interval check.
24
+ */
25
+ onIntervalExpiration?: (entry: CacheEntry) => void;
26
+ };
27
+ //#endregion
28
+ export { MemoryCacheOptions, MemoryCacheProvider };
@@ -0,0 +1,28 @@
1
+ import { CacheEntry, CacheInvalidationOptions, CacheProvider } from "../types.mjs";
2
+
3
+ //#region src/providers/memory.d.ts
4
+ declare class MemoryCacheProvider implements CacheProvider {
5
+ private readonly options?;
6
+ private readonly entryStore;
7
+ private readonly tagStore;
8
+ constructor(options?: MemoryCacheOptions | undefined);
9
+ private checkExpiration;
10
+ get(key: string): Promise<CacheEntry | undefined>;
11
+ set(key: string, entry: CacheEntry): Promise<void>;
12
+ invalidate(options: CacheInvalidationOptions): Promise<void>;
13
+ invalidateAll(): Promise<void>;
14
+ }
15
+ type MemoryCacheOptions = {
16
+ /**
17
+ * How often, in seconds, entries will be checked for expiration.
18
+ *
19
+ * @default 60
20
+ */
21
+ checkInterval?: number;
22
+ /**
23
+ * Called when an entry has expired via the interval check.
24
+ */
25
+ onIntervalExpiration?: (entry: CacheEntry) => void;
26
+ };
27
+ //#endregion
28
+ export { MemoryCacheOptions, MemoryCacheProvider };
@@ -0,0 +1,55 @@
1
+ import { entryIsExpired } from "../utils.mjs";
2
+
3
+ //#region src/providers/memory.ts
4
+ var MemoryCacheProvider = class {
5
+ entryStore;
6
+ tagStore;
7
+ constructor(options) {
8
+ this.options = options;
9
+ this.entryStore = /* @__PURE__ */ new Map();
10
+ this.tagStore = /* @__PURE__ */ new Map();
11
+ setInterval(() => {
12
+ this.checkExpiration();
13
+ }, (this.options?.checkInterval ?? 60) * 1e3).unref();
14
+ }
15
+ checkExpiration() {
16
+ for (const [key, entry] of this.entryStore) if (entryIsExpired(entry)) {
17
+ this.entryStore.delete(key);
18
+ this.options?.onIntervalExpiration?.(entry);
19
+ }
20
+ for (const [tag, keys] of this.tagStore) {
21
+ for (const key of keys) if (!this.entryStore.has(key)) keys.delete(key);
22
+ if (keys.size === 0) this.tagStore.delete(tag);
23
+ }
24
+ }
25
+ get(key) {
26
+ return Promise.resolve(this.entryStore.get(key));
27
+ }
28
+ set(key, entry) {
29
+ this.entryStore.set(key, entry);
30
+ if (entry.options.tags) for (const tag of entry.options.tags) {
31
+ let keys = this.tagStore.get(tag);
32
+ if (!keys) {
33
+ keys = /* @__PURE__ */ new Set();
34
+ this.tagStore.set(tag, keys);
35
+ }
36
+ keys.add(key);
37
+ }
38
+ return Promise.resolve();
39
+ }
40
+ invalidate(options) {
41
+ if (options.tags) for (const tag of options.tags) {
42
+ const keys = this.tagStore.get(tag);
43
+ if (keys) for (const key of keys) this.entryStore.delete(key);
44
+ }
45
+ return Promise.resolve();
46
+ }
47
+ invalidateAll() {
48
+ this.entryStore.clear();
49
+ this.tagStore.clear();
50
+ return Promise.resolve();
51
+ }
52
+ };
53
+
54
+ //#endregion
55
+ export { MemoryCacheProvider };
@@ -0,0 +1,68 @@
1
+ const require_rolldown_runtime = require('../_virtual/rolldown_runtime.cjs');
2
+ const require_utils = require('../utils.cjs');
3
+ require('../superjson.cjs');
4
+ let ioredis = require("ioredis");
5
+ let superjson = require("superjson");
6
+
7
+ //#region src/providers/redis.ts
8
+ var RedisCacheProvider = class {
9
+ redis;
10
+ constructor(options) {
11
+ this.redis = new ioredis.Redis(options.url);
12
+ }
13
+ async get(key) {
14
+ const entryJson = await this.redis.get(formatQueryKey(key));
15
+ if (!entryJson) return;
16
+ return superjson.default.parse(entryJson);
17
+ }
18
+ async set(key, entry) {
19
+ const multi = this.redis.multi();
20
+ const formattedKey = formatQueryKey(key);
21
+ multi.set(formattedKey, superjson.default.stringify(entry));
22
+ const totalTtl = require_utils.getTotalTtl(entry);
23
+ if (totalTtl > 0) multi.expire(formattedKey, totalTtl);
24
+ if (entry.options.tags) for (const tag of entry.options.tags) {
25
+ const formattedTagKey = formatTagKey(tag);
26
+ multi.sadd(formattedTagKey, formattedKey);
27
+ if (totalTtl > 0) {
28
+ multi.expire(formattedTagKey, totalTtl, "GT");
29
+ multi.expire(formattedTagKey, totalTtl, "NX");
30
+ }
31
+ }
32
+ await multi.exec();
33
+ }
34
+ async invalidate(options) {
35
+ if (options.tags && options.tags.length > 0) await Promise.all(options.tags.map((tag) => {
36
+ return new Promise((resolve, reject) => {
37
+ const stream = this.redis.sscanStream(formatTagKey(tag), { count: 100 });
38
+ stream.on("data", async (keys) => {
39
+ if (keys.length > 1) await this.redis.del(...keys);
40
+ });
41
+ stream.on("error", reject);
42
+ stream.on("end", resolve);
43
+ });
44
+ }));
45
+ }
46
+ async invalidateAll() {
47
+ await new Promise((resolve, reject) => {
48
+ const stream = this.redis.scanStream({
49
+ count: 100,
50
+ match: "zenstack:cache:*"
51
+ });
52
+ stream.on("data", async (keys) => {
53
+ if (keys.length > 1) await this.redis.del(...keys);
54
+ });
55
+ stream.on("error", reject);
56
+ stream.on("end", resolve);
57
+ });
58
+ }
59
+ };
60
+ function formatQueryKey(key) {
61
+ return `zenstack:cache:query:${key}`;
62
+ }
63
+ function formatTagKey(key) {
64
+ return `zenstack:cache:tag:${key}`;
65
+ }
66
+
67
+ //#endregion
68
+ exports.RedisCacheProvider = RedisCacheProvider;
@@ -0,0 +1,16 @@
1
+ import { CacheEntry, CacheInvalidationOptions, CacheProvider } from "../types.cjs";
2
+
3
+ //#region src/providers/redis.d.ts
4
+ declare class RedisCacheProvider implements CacheProvider {
5
+ private readonly redis;
6
+ constructor(options: RedisCacheProviderOptions);
7
+ get(key: string): Promise<CacheEntry | undefined>;
8
+ set(key: string, entry: CacheEntry): Promise<void>;
9
+ invalidate(options: CacheInvalidationOptions): Promise<void>;
10
+ invalidateAll(): Promise<void>;
11
+ }
12
+ type RedisCacheProviderOptions = {
13
+ url: string;
14
+ };
15
+ //#endregion
16
+ export { RedisCacheProvider, RedisCacheProviderOptions };
@@ -0,0 +1,16 @@
1
+ import { CacheEntry, CacheInvalidationOptions, CacheProvider } from "../types.mjs";
2
+
3
+ //#region src/providers/redis.d.ts
4
+ declare class RedisCacheProvider implements CacheProvider {
5
+ private readonly redis;
6
+ constructor(options: RedisCacheProviderOptions);
7
+ get(key: string): Promise<CacheEntry | undefined>;
8
+ set(key: string, entry: CacheEntry): Promise<void>;
9
+ invalidate(options: CacheInvalidationOptions): Promise<void>;
10
+ invalidateAll(): Promise<void>;
11
+ }
12
+ type RedisCacheProviderOptions = {
13
+ url: string;
14
+ };
15
+ //#endregion
16
+ export { RedisCacheProvider, RedisCacheProviderOptions };
@@ -0,0 +1,66 @@
1
+ import { getTotalTtl } from "../utils.mjs";
2
+ import { superjson } from "../superjson.mjs";
3
+ import { Redis } from "ioredis";
4
+
5
+ //#region src/providers/redis.ts
6
+ var RedisCacheProvider = class {
7
+ redis;
8
+ constructor(options) {
9
+ this.redis = new Redis(options.url);
10
+ }
11
+ async get(key) {
12
+ const entryJson = await this.redis.get(formatQueryKey(key));
13
+ if (!entryJson) return;
14
+ return superjson.parse(entryJson);
15
+ }
16
+ async set(key, entry) {
17
+ const multi = this.redis.multi();
18
+ const formattedKey = formatQueryKey(key);
19
+ multi.set(formattedKey, superjson.stringify(entry));
20
+ const totalTtl = getTotalTtl(entry);
21
+ if (totalTtl > 0) multi.expire(formattedKey, totalTtl);
22
+ if (entry.options.tags) for (const tag of entry.options.tags) {
23
+ const formattedTagKey = formatTagKey(tag);
24
+ multi.sadd(formattedTagKey, formattedKey);
25
+ if (totalTtl > 0) {
26
+ multi.expire(formattedTagKey, totalTtl, "GT");
27
+ multi.expire(formattedTagKey, totalTtl, "NX");
28
+ }
29
+ }
30
+ await multi.exec();
31
+ }
32
+ async invalidate(options) {
33
+ if (options.tags && options.tags.length > 0) await Promise.all(options.tags.map((tag) => {
34
+ return new Promise((resolve, reject) => {
35
+ const stream = this.redis.sscanStream(formatTagKey(tag), { count: 100 });
36
+ stream.on("data", async (keys) => {
37
+ if (keys.length > 1) await this.redis.del(...keys);
38
+ });
39
+ stream.on("error", reject);
40
+ stream.on("end", resolve);
41
+ });
42
+ }));
43
+ }
44
+ async invalidateAll() {
45
+ await new Promise((resolve, reject) => {
46
+ const stream = this.redis.scanStream({
47
+ count: 100,
48
+ match: "zenstack:cache:*"
49
+ });
50
+ stream.on("data", async (keys) => {
51
+ if (keys.length > 1) await this.redis.del(...keys);
52
+ });
53
+ stream.on("error", reject);
54
+ stream.on("end", resolve);
55
+ });
56
+ }
57
+ };
58
+ function formatQueryKey(key) {
59
+ return `zenstack:cache:query:${key}`;
60
+ }
61
+ function formatTagKey(key) {
62
+ return `zenstack:cache:tag:${key}`;
63
+ }
64
+
65
+ //#endregion
66
+ export { RedisCacheProvider };
@@ -0,0 +1,15 @@
1
+ const require_rolldown_runtime = require('./_virtual/rolldown_runtime.cjs');
2
+ let zod = require("zod");
3
+ zod = require_rolldown_runtime.__toESM(zod);
4
+
5
+ //#region src/schemas.ts
6
+ const cacheOptionsSchema = zod.default.strictObject({
7
+ ttl: zod.default.number().min(1).optional(),
8
+ swr: zod.default.number().min(1).optional(),
9
+ tags: zod.default.string().array().optional()
10
+ });
11
+ const cacheEnvelopeSchema = zod.default.object({ cache: cacheOptionsSchema.optional() });
12
+
13
+ //#endregion
14
+ exports.cacheEnvelopeSchema = cacheEnvelopeSchema;
15
+ exports.cacheOptionsSchema = cacheOptionsSchema;
@@ -0,0 +1,17 @@
1
+ import z from "zod";
2
+
3
+ //#region src/schemas.d.ts
4
+ declare const cacheOptionsSchema: z.ZodObject<{
5
+ ttl: z.ZodOptional<z.ZodNumber>;
6
+ swr: z.ZodOptional<z.ZodNumber>;
7
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
8
+ }, z.core.$strict>;
9
+ declare const cacheEnvelopeSchema: z.ZodObject<{
10
+ cache: z.ZodOptional<z.ZodObject<{
11
+ ttl: z.ZodOptional<z.ZodNumber>;
12
+ swr: z.ZodOptional<z.ZodNumber>;
13
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
14
+ }, z.core.$strict>>;
15
+ }, z.core.$strip>;
16
+ //#endregion
17
+ export { cacheEnvelopeSchema, cacheOptionsSchema };
@@ -0,0 +1,17 @@
1
+ import z from "zod";
2
+
3
+ //#region src/schemas.d.ts
4
+ declare const cacheOptionsSchema: z.ZodObject<{
5
+ ttl: z.ZodOptional<z.ZodNumber>;
6
+ swr: z.ZodOptional<z.ZodNumber>;
7
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
8
+ }, z.core.$strict>;
9
+ declare const cacheEnvelopeSchema: z.ZodObject<{
10
+ cache: z.ZodOptional<z.ZodObject<{
11
+ ttl: z.ZodOptional<z.ZodNumber>;
12
+ swr: z.ZodOptional<z.ZodNumber>;
13
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
14
+ }, z.core.$strict>>;
15
+ }, z.core.$strip>;
16
+ //#endregion
17
+ export { cacheEnvelopeSchema, cacheOptionsSchema };
@@ -0,0 +1,12 @@
1
+ import z from "zod";
2
+
3
+ //#region src/schemas.ts
4
+ const cacheOptionsSchema = z.strictObject({
5
+ ttl: z.number().min(1).optional(),
6
+ swr: z.number().min(1).optional(),
7
+ tags: z.string().array().optional()
8
+ });
9
+ const cacheEnvelopeSchema = z.object({ cache: cacheOptionsSchema.optional() });
10
+
11
+ //#endregion
12
+ export { cacheEnvelopeSchema, cacheOptionsSchema };
@@ -0,0 +1,14 @@
1
+ const require_rolldown_runtime = require('./_virtual/rolldown_runtime.cjs');
2
+ let decimal_js = require("decimal.js");
3
+ let superjson = require("superjson");
4
+ superjson = require_rolldown_runtime.__toESM(superjson);
5
+
6
+ //#region src/superjson.ts
7
+ superjson.default.registerCustom({
8
+ isApplicable: (v) => decimal_js.Decimal.isDecimal(v),
9
+ serialize: (v) => v.toJSON(),
10
+ deserialize: (v) => new decimal_js.Decimal(v)
11
+ }, "decimal.js");
12
+
13
+ //#endregion
14
+ exports.superjson = superjson.default;
@@ -0,0 +1,2 @@
1
+ import superjson from "superjson";
2
+ export { superjson };
@@ -0,0 +1,2 @@
1
+ import superjson from "superjson";
2
+ export { superjson };
@@ -0,0 +1,12 @@
1
+ import { Decimal } from "decimal.js";
2
+ import superjson from "superjson";
3
+
4
+ //#region src/superjson.ts
5
+ superjson.registerCustom({
6
+ isApplicable: (v) => Decimal.isDecimal(v),
7
+ serialize: (v) => v.toJSON(),
8
+ deserialize: (v) => new Decimal(v)
9
+ }, "decimal.js");
10
+
11
+ //#endregion
12
+ export { superjson };
package/dist/types.cjs ADDED
File without changes
@@ -0,0 +1,35 @@
1
+ import { cacheEnvelopeSchema, cacheOptionsSchema } from "./schemas.cjs";
2
+ import z from "zod";
3
+
4
+ //#region src/types.d.ts
5
+ type CacheEnvelope = z.infer<typeof cacheEnvelopeSchema>;
6
+ type CacheOptions = z.infer<typeof cacheOptionsSchema>;
7
+ interface CacheProvider {
8
+ get: (key: string) => Promise<CacheEntry | undefined>;
9
+ set: (key: string, entry: CacheEntry) => Promise<void>;
10
+ invalidate: (options: CacheInvalidationOptions) => Promise<void>;
11
+ invalidateAll: () => Promise<void>;
12
+ }
13
+ type CacheInvalidationOptions = {
14
+ tags?: string[];
15
+ };
16
+ type CacheEntry = {
17
+ /**
18
+ * In unix epoch milliseconds.
19
+ */
20
+ createdAt: number;
21
+ /**
22
+ * The caching options that were passed to the query.
23
+ */
24
+ options: CacheOptions;
25
+ /**
26
+ * The result of executing the query.
27
+ */
28
+ result: unknown;
29
+ };
30
+ type CachePluginOptions = {
31
+ provider: CacheProvider;
32
+ };
33
+ type CacheStatus = 'hit' | 'miss' | 'stale';
34
+ //#endregion
35
+ export { CacheEntry, CacheEnvelope, CacheInvalidationOptions, CacheOptions, CachePluginOptions, CacheProvider, CacheStatus };
@@ -0,0 +1,35 @@
1
+ import { cacheEnvelopeSchema, cacheOptionsSchema } from "./schemas.mjs";
2
+ import z from "zod";
3
+
4
+ //#region src/types.d.ts
5
+ type CacheEnvelope = z.infer<typeof cacheEnvelopeSchema>;
6
+ type CacheOptions = z.infer<typeof cacheOptionsSchema>;
7
+ interface CacheProvider {
8
+ get: (key: string) => Promise<CacheEntry | undefined>;
9
+ set: (key: string, entry: CacheEntry) => Promise<void>;
10
+ invalidate: (options: CacheInvalidationOptions) => Promise<void>;
11
+ invalidateAll: () => Promise<void>;
12
+ }
13
+ type CacheInvalidationOptions = {
14
+ tags?: string[];
15
+ };
16
+ type CacheEntry = {
17
+ /**
18
+ * In unix epoch milliseconds.
19
+ */
20
+ createdAt: number;
21
+ /**
22
+ * The caching options that were passed to the query.
23
+ */
24
+ options: CacheOptions;
25
+ /**
26
+ * The result of executing the query.
27
+ */
28
+ result: unknown;
29
+ };
30
+ type CachePluginOptions = {
31
+ provider: CacheProvider;
32
+ };
33
+ type CacheStatus = 'hit' | 'miss' | 'stale';
34
+ //#endregion
35
+ export { CacheEntry, CacheEnvelope, CacheInvalidationOptions, CacheOptions, CachePluginOptions, CacheProvider, CacheStatus };
package/dist/types.mjs ADDED
@@ -0,0 +1 @@
1
+ export { };
package/dist/utils.cjs ADDED
@@ -0,0 +1,20 @@
1
+
2
+ //#region src/utils.ts
3
+ function getTotalTtl(entry) {
4
+ return (entry.options.ttl ?? 0) + (entry.options.swr ?? 0);
5
+ }
6
+ function entryIsFresh(entry) {
7
+ return entry.options.ttl ? Date.now() <= entry.createdAt + (entry.options.ttl ?? 0) * 1e3 : false;
8
+ }
9
+ function entryIsStale(entry) {
10
+ return entry.options.swr ? Date.now() <= entry.createdAt + getTotalTtl(entry) * 1e3 : false;
11
+ }
12
+ function entryIsExpired(entry) {
13
+ return Date.now() > entry.createdAt + getTotalTtl(entry) * 1e3;
14
+ }
15
+
16
+ //#endregion
17
+ exports.entryIsExpired = entryIsExpired;
18
+ exports.entryIsFresh = entryIsFresh;
19
+ exports.entryIsStale = entryIsStale;
20
+ exports.getTotalTtl = getTotalTtl;
@@ -0,0 +1,9 @@
1
+ import { CacheEntry } from "./types.cjs";
2
+
3
+ //#region src/utils.d.ts
4
+ declare function getTotalTtl(entry: CacheEntry): number;
5
+ declare function entryIsFresh(entry: CacheEntry): boolean;
6
+ declare function entryIsStale(entry: CacheEntry): boolean;
7
+ declare function entryIsExpired(entry: CacheEntry): boolean;
8
+ //#endregion
9
+ export { entryIsExpired, entryIsFresh, entryIsStale, getTotalTtl };
@@ -0,0 +1,9 @@
1
+ import { CacheEntry } from "./types.mjs";
2
+
3
+ //#region src/utils.d.ts
4
+ declare function getTotalTtl(entry: CacheEntry): number;
5
+ declare function entryIsFresh(entry: CacheEntry): boolean;
6
+ declare function entryIsStale(entry: CacheEntry): boolean;
7
+ declare function entryIsExpired(entry: CacheEntry): boolean;
8
+ //#endregion
9
+ export { entryIsExpired, entryIsFresh, entryIsStale, getTotalTtl };
package/dist/utils.mjs ADDED
@@ -0,0 +1,16 @@
1
+ //#region src/utils.ts
2
+ function getTotalTtl(entry) {
3
+ return (entry.options.ttl ?? 0) + (entry.options.swr ?? 0);
4
+ }
5
+ function entryIsFresh(entry) {
6
+ return entry.options.ttl ? Date.now() <= entry.createdAt + (entry.options.ttl ?? 0) * 1e3 : false;
7
+ }
8
+ function entryIsStale(entry) {
9
+ return entry.options.swr ? Date.now() <= entry.createdAt + getTotalTtl(entry) * 1e3 : false;
10
+ }
11
+ function entryIsExpired(entry) {
12
+ return Date.now() > entry.createdAt + getTotalTtl(entry) * 1e3;
13
+ }
14
+
15
+ //#endregion
16
+ export { entryIsExpired, entryIsFresh, entryIsStale, getTotalTtl };
package/package.json ADDED
@@ -0,0 +1,115 @@
1
+ {
2
+ "name": "@visualbravo/zenstack-cache",
3
+ "version": "0.0.0",
4
+ "type": "module",
5
+ "main": "./dist/index.cjs",
6
+ "module": "./dist/index.mjs",
7
+ "types": "./dist/index.d.cts",
8
+ "exports": {
9
+ ".": {
10
+ "@zenstack-cache/source": "./src/index.ts",
11
+ "require": "./dist/index.cjs",
12
+ "import": "./dist/index.mjs"
13
+ },
14
+ "./plugin": {
15
+ "@zenstack-cache/source": "./src/plugin.ts",
16
+ "require": "./dist/plugin.cjs",
17
+ "import": "./dist/plugin.mjs"
18
+ },
19
+ "./providers/memory": {
20
+ "@zenstack-cache/source": "./src/providers/memory.ts",
21
+ "require": "./dist/providers/memory.cjs",
22
+ "import": "./dist/providers/memory.mjs"
23
+ },
24
+ "./providers/redis": {
25
+ "@zenstack-cache/source": "./src/providers/redis.ts",
26
+ "require": "./dist/providers/redis.cjs",
27
+ "import": "./dist/providers/redis.mjs"
28
+ },
29
+ "./schemas": {
30
+ "@zenstack-cache/source": "./src/schemas.ts",
31
+ "require": "./dist/schemas.cjs",
32
+ "import": "./dist/schemas.mjs"
33
+ },
34
+ "./superjson": {
35
+ "@zenstack-cache/source": "./src/superjson.ts",
36
+ "require": "./dist/superjson.cjs",
37
+ "import": "./dist/superjson.mjs"
38
+ },
39
+ "./types": {
40
+ "@zenstack-cache/source": "./src/types.ts",
41
+ "require": "./dist/types.cjs",
42
+ "import": "./dist/types.mjs"
43
+ },
44
+ "./utils": {
45
+ "@zenstack-cache/source": "./src/utils.ts",
46
+ "require": "./dist/utils.cjs",
47
+ "import": "./dist/utils.mjs"
48
+ },
49
+ "./package.json": "./package.json"
50
+ },
51
+ "publishConfig": {
52
+ "exports": {
53
+ ".": {
54
+ "require": "./dist/index.cjs",
55
+ "import": "./dist/index.mjs"
56
+ },
57
+ "./plugin": {
58
+ "require": "./dist/plugin.cjs",
59
+ "import": "./dist/plugin.mjs"
60
+ },
61
+ "./providers/memory": {
62
+ "require": "./dist/providers/memory.cjs",
63
+ "import": "./dist/providers/memory.mjs"
64
+ },
65
+ "./providers/redis": {
66
+ "require": "./dist/providers/redis.cjs",
67
+ "import": "./dist/providers/redis.mjs"
68
+ },
69
+ "./schemas": {
70
+ "require": "./dist/schemas.cjs",
71
+ "import": "./dist/schemas.mjs"
72
+ },
73
+ "./superjson": {
74
+ "require": "./dist/superjson.cjs",
75
+ "import": "./dist/superjson.mjs"
76
+ },
77
+ "./types": {
78
+ "require": "./dist/types.cjs",
79
+ "import": "./dist/types.mjs"
80
+ },
81
+ "./utils": {
82
+ "require": "./dist/utils.cjs",
83
+ "import": "./dist/utils.mjs"
84
+ },
85
+ "./package.json": "./package.json"
86
+ }
87
+ },
88
+ "scripts": {
89
+ "build": "tsdown",
90
+ "watch": "tsdown --watch",
91
+ "lint": "oxlint",
92
+ "lint:fix": "oxlint --fix --fix-suggestions",
93
+ "format:fix": "oxfmt",
94
+ "test": "vitest run",
95
+ "test:watch": "vitest --watch"
96
+ },
97
+ "dependencies": {
98
+ "decimal.js": "^10.6.0",
99
+ "ioredis": "^5.0.0",
100
+ "murmurhash": "^2.0.1",
101
+ "stable-hash": "^0.0.6",
102
+ "superjson": "^2.2.2",
103
+ "zod": "^4.1.0"
104
+ },
105
+ "devDependencies": {
106
+ "@zenstack-cache/config": "0.0.0",
107
+ "kysely": "~0.28.8",
108
+ "better-sqlite3": "12.6.2",
109
+ "@types/better-sqlite3": "7.6.13"
110
+ },
111
+ "peerDependencies": {
112
+ "@zenstackhq/orm": "canary"
113
+ },
114
+ "packageManager": "bun@1.3.6"
115
+ }