import { createTwoTierCache, type TwoTierCache } from "@toolctrl/shared-store"; import { getSharedStore } from "~/shared-store.server"; import { clearCacheKey, clearCacheKeyPrefix } from "~/utils/cache.server"; import { createScopedLogger } from "~/utils/logger.server"; /** * Tenant cache (two-tier). The existing in-process `cachified` LRU in * `tenants.db.server.ts` stays the L1 read path; this module owns the * pub/sub channel so peer replicas drop their L1 entries when a * mutation happens on a different replica. * * Keys broadcast over the channel are tenant ids. The subscriber clears * every cachified key derived from a tenant (`tenant:`, `tenantSimple:`, * `tenantIdOrSlug:`). */ const log = createScopedLogger("tenant-cache"); const TTL_SECONDS_DEFAULT = 5 * 60; let cacheSingleton: TwoTierCache<{ id: string } | null> | null = null; let subscribeDisposer: (() => Promise) | null = null; function pubsubEnabledFromEnv(): boolean { const raw = process.env.CACHE_INVALIDATION_ENABLED; if (raw === undefined || raw === null || raw === "") return true; return raw.toLowerCase() !== "false" && raw !== "0"; } function ttlSecondsFromEnv(): number { const raw = process.env.CACHE_L1_TTL_SECONDS; if (!raw) return TTL_SECONDS_DEFAULT; const parsed = Number(raw); if (!Number.isFinite(parsed) || parsed <= 0) return TTL_SECONDS_DEFAULT; return Math.floor(parsed); } export function getTenantCache(): TwoTierCache<{ id: string } | null> { if (cacheSingleton) return cacheSingleton; cacheSingleton = createTwoTierCache<{ id: string } | null>({ store: getSharedStore(), namespace: "tenant", ttlSeconds: ttlSecondsFromEnv(), pubsubEnabled: pubsubEnabledFromEnv(), logger: { warn: (obj, msg) => log.warn(obj, msg), error: (obj, msg) => log.error(obj, msg) }, }); return cacheSingleton; } /** * Publish a tenant-invalidation. Call after any tenant mutation in the * web app (settings update, logo upload, member add/remove). The local * cachified entries are also cleared as a defence-in-depth in case the * caller forgot to call clearCacheKey(). */ export async function publishTenantInvalidation(tenantId: string): Promise { clearCacheKey(`tenant:${tenantId}`); clearCacheKey(`tenantSimple:${tenantId}`); clearCacheKey(`tenantIdOrSlug:${tenantId}`); await getTenantCache().invalidate(tenantId).catch(() => { /* logged inside helper */ }); } export async function subscribeTenantCache(): Promise<() => Promise> { if (subscribeDisposer) return subscribeDisposer; const cache = getTenantCache(); subscribeDisposer = await cache.subscribe((key) => { if (!key || key === "*") { clearCacheKeyPrefix("tenant:"); clearCacheKeyPrefix("tenantSimple:"); clearCacheKeyPrefix("tenantIdOrSlug:"); return; } clearCacheKey(`tenant:${key}`); clearCacheKey(`tenantSimple:${key}`); clearCacheKey(`tenantIdOrSlug:${key}`); }); log.info({ namespace: "tenant" }, "tenant cache subscriber started"); return subscribeDisposer; } /** Test seam: drop the singleton + disposer. */ export function __resetTenantCacheForTests(): void { cacheSingleton = null; subscribeDisposer = null; }