1import { createHash } from "node:crypto";
2import { Inject, Injectable, Optional, type OnModuleInit } from "@nestjs/common";
3import type Redis from "ioredis";
4import { z } from "zod";
5import { EVENTS } from "../constants.js";
6import { loadEnv } from "../config/env.js";
7import { logger } from "./logger.js";
8import { EventBus } from "./event-bus.js";
9import { RedisService } from "./redis.service.js";
10
11
38
39export const CACHE_TTLS = {
40
41 PERMISSION_MS: 60_000,
42
43 METADATA_MS: 60_000,
44
45 SEARCH_MS: 5_000
46} as const;
47
48const PREFIX = {
49 tree: "perf:tree",
50 version: "perf:version",
51 perm: "perf:perm",
52 search: "perf:search",
53 metadata: "perf:metadata"
54} as const;
55
56
57export interface StructuredEntry<T> {
58 schema: z.ZodType<T>;
59}
60
61function key(prefix: string, ...parts: (string | number)[]): string {
62 return [prefix, ...parts].join(":");
63}
64
65
66function hash(parts: unknown[]): string {
67 return createHash("sha256").update(JSON.stringify(parts)).digest("hex").slice(0, 32);
68}
69
70@Injectable()
71export class CacheService implements OnModuleInit {
72 constructor(
73 @Optional() @Inject(RedisService) private readonly redisProvider?: RedisService,
74 @Optional() @Inject(EventBus) private readonly eventsProvider?: EventBus
75 ) {}
76
77
84 private readonly mode: "on" | "off" = (() => {
85 const env = loadEnv();
86 const on = env.PERF_CACHE === "on" || (env.PERF_CACHE === "auto" && env.NODE_ENV === "production");
87 return on ? "on" : "off";
88 })();
89
90
91 isOn(): boolean {
92 return this.mode === "on";
93 }
94
95 onModuleInit(): void {
96 if (this.isOn() && this.eventsProvider) this.wireInvalidation(this.eventsProvider);
97 }
98
99 private get redis(): Redis | null {
100 try {
101 return this.redisProvider?.getRedis() ?? null;
102 } catch {
103 return null;
104 }
105 }
106
107
111 wireInvalidation(events: EventBus): void {
112
113
114
115
116
117
118
119
120
121 events.onLocal(EVENTS.PERMISSION_CHANGED, (body) => {
122 let payload: Record<string, unknown> = {};
123 try {
124 payload = JSON.parse(body.payload);
125 } catch {
126 return;
127 }
128 const workspaceId = payload.workspaceId as string | undefined;
129 const userId = payload.userId as string | undefined;
130 const r = this.redis;
131 if (!r || !workspaceId) return;
132 const patterns = [key(PREFIX.perm, workspaceId, userId ?? "*", "*")];
133 this.scanDel(r, patterns);
134 });
135
136
137
138
139
140
141 for (const event of [EVENTS.PAGE_UPDATED, EVENTS.PAGE_CREATED, EVENTS.PAGE_DELETED]) {
142 events.onLocal(event, (body) => {
143 let payload: Record<string, unknown> = {};
144 try {
145 payload = JSON.parse(body.payload);
146 } catch {
147 return;
148 }
149 const workspaceId = payload.workspaceId as string | undefined;
150 if (workspaceId) void this.bumpVersion(workspaceId);
151 });
152 }
153
154
155
156
157
158
159 events.onLocal(EVENTS.DATABASE_UPDATED, (body) => {
160 let payload: Record<string, unknown> = {};
161 try {
162 payload = JSON.parse(body.payload);
163 } catch {
164 return;
165 }
166 const databaseId = payload.databaseId as string | undefined;
167 const r = this.redis;
168 if (databaseId && r) this.scanDel(r, [key(PREFIX.metadata, databaseId, "*")]);
169 });
170 }
171
172
173
174
175
176
177 async getVersion(workspaceId: string): Promise<number> {
178 const r = this.redis;
179 if (!r || !this.isOn()) return 0;
180 try {
181 const v = await r.get(key(PREFIX.version, workspaceId));
182 return v ? Number(v) : 0;
183 } catch (err) {
184 logger.warn(`cache.getVersion failed: ${(err as Error).message}`);
185 return 0;
186 }
187 }
188
189
190 async bumpVersion(workspaceId: string): Promise<number> {
191 const r = this.redis;
192 if (!r) return 0;
193 try {
194 return await r.incr(key(PREFIX.version, workspaceId));
195 } catch (err) {
196 logger.warn(`cache.bumpVersion failed: ${(err as Error).message}`);
197 return 0;
198 }
199 }
200
201
202
203
204
205 async getStructured<T>(fullKey: string, entry: StructuredEntry<T>): Promise<T | null> {
206 const r = this.redis;
207 if (!r || !this.isOn()) return null;
208 try {
209 const raw = await r.get(fullKey);
210 if (!raw) return null;
211 let parsed: unknown;
212 try {
213 parsed = JSON.parse(raw);
214 } catch {
215
216 await r.del(fullKey).catch(() => undefined);
217 return null;
218 }
219 const result = entry.schema.safeParse(parsed);
220 if (!result.success) {
221
222 await r.del(fullKey).catch(() => undefined);
223 return null;
224 }
225 return result.data;
226 } catch (err) {
227 logger.warn(`cache.getStructured failed: ${(err as Error).message}`);
228 return null;
229 }
230 }
231
232 async setStructured(fullKey: string, value: unknown, ttlMs: number): Promise<void> {
233 const r = this.redis;
234 if (!r || !this.isOn()) return;
235 try {
236 await r.set(fullKey, JSON.stringify(value), "PX", ttlMs);
237 } catch (err) {
238 logger.warn(`cache.setStructured failed: ${(err as Error).message}`);
239 }
240 }
241
242 private scanDel(r: Redis, patterns: string[]): void {
243 for (const pattern of patterns) {
244 void (async () => {
245 let cursor = "0";
246 do {
247 const [next, keys] = await r.scan(cursor, "MATCH", pattern, "COUNT", 200).catch(() => ["0", []] as [string, string[]]);
248 if (keys.length) await r.del(...keys).catch(() => undefined);
249 cursor = next;
250 } while (cursor !== "0");
251 })().catch(() => undefined);
252 }
253 }
254
255
256
257
258
259
267 async getTree(
268 workspaceId: string,
269 pageId: string,
270 loader: () => Promise<unknown[]>,
271 schema: z.ZodType<unknown[]>
272 ): Promise<{ data: unknown[]; cached: boolean }> {
273 if (!this.isOn()) return { data: await loader(), cached: false };
274 const version = await this.getVersion(workspaceId);
275 if (version > 0) {
276 const fullKey = key(PREFIX.tree, workspaceId, pageId, version);
277 const hit = await this.getStructured(fullKey, { schema });
278 if (hit) return { data: hit, cached: true };
279 const data = await loader();
280 await this.setStructured(fullKey, data, CACHE_TTLS.PERMISSION_MS);
281 return { data, cached: false };
282 }
283 return { data: await loader(), cached: false };
284 }
285
286
287
288
289
290
296 async getPermission(
297 workspaceId: string,
298 userId: string,
299 scope: { pageId?: string; databaseId?: string; recordId?: string; fileId?: string; teamId?: string } | undefined,
300 loader: () => Promise<string>,
301 schema: z.ZodType<string>
302 ): Promise<{ level: string; cached: boolean }> {
303 const scopeHash = hash(Object.entries(scope ?? {}).filter(([, v]) => v != null).map(([k, v]) => [k, v]));
304 const fullKey = key(PREFIX.perm, workspaceId, userId, scopeHash);
305 const hit = await this.getStructured(fullKey, { schema });
306 if (hit) return { level: hit, cached: true };
307 const level = await loader();
308 await this.setStructured(fullKey, level, CACHE_TTLS.PERMISSION_MS);
309 return { level, cached: false };
310 }
311
312
313
314
315
316 async getSearch(
317 workspaceId: string | undefined,
318 principalId: string,
319 input: Record<string, unknown>,
320 loader: () => Promise<unknown>,
321 schema: z.ZodType<unknown>
322 ): Promise<{ data: unknown; cached: boolean }> {
323
324
325 const { q, ...filters } = input;
326 const fullKey = key(
327 PREFIX.search,
328 workspaceId ?? "global",
329 principalId,
330 hash([String(q ?? ""), filters])
331 );
332 const hit = await this.getStructured(fullKey, { schema });
333 if (hit) return { data: hit, cached: true };
334 const data = await loader();
335 await this.setStructured(fullKey, data, CACHE_TTLS.SEARCH_MS);
336 return { data, cached: false };
337 }
338
339
340
341
342
343 async getMetadata(
344 databaseId: string,
345 kind: "properties" | "views",
346 loader: () => Promise<unknown>,
347 schema: z.ZodType<unknown>
348 ): Promise<{ data: unknown; cached: boolean }> {
349 const fullKey = key(PREFIX.metadata, databaseId, kind);
350 const hit = await this.getStructured(fullKey, { schema });
351 if (hit) return { data: hit, cached: true };
352 const data = await loader();
353 await this.setStructured(fullKey, data, CACHE_TTLS.METADATA_MS);
354 return { data, cached: false };
355 }
356}
357