import { createTwoTierCache, type TwoTierCache } from "@toolctrl/shared-store"; import type { TenantSubscriptionWithDetails } from "@toolctrl/db/subscriptions.server"; import { getSharedStore } from "~/shared-store.server"; import { clearCacheKey, clearCacheKeyPrefix } from "~/utils/cache.server"; import { createScopedLogger } from "~/utils/logger.server"; /** * Subscription cache (two-tier). L1 = in-process Map, L2 = SharedStore; * cross-replica purges via pub/sub on channel * `shared-store:web:ch:cache:subscription:invalidate`. * * Bootstrap (idempotent): call `subscribeSubscriptionCache()` once during * web boot so this replica listens for invalidation messages emitted by * the API stripe webhook handler. */ const log = createScopedLogger("subscription-cache"); const TTL_SECONDS_DEFAULT = 5 * 60; // 5 minutes — L1 safety-net so a missed pub/sub event self-heals quickly. let cacheSingleton: TwoTierCache | 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 getSubscriptionCache(): TwoTierCache { if (cacheSingleton) return cacheSingleton; cacheSingleton = createTwoTierCache({ store: getSharedStore(), namespace: "subscription", ttlSeconds: ttlSecondsFromEnv(), pubsubEnabled: pubsubEnabledFromEnv(), logger: { warn: (obj, msg) => log.warn(obj, msg), error: (obj, msg) => log.error(obj, msg) }, }); return cacheSingleton; } /** * Start the subscriber for this process. Safe to call repeatedly (idempotent). * Returns the disposer for tests / graceful shutdown. */ export async function subscribeSubscriptionCache(): Promise<() => Promise> { if (subscribeDisposer) return subscribeDisposer; const cache = getSubscriptionCache(); // Bridge: on remote invalidation, also purge the in-process cachified // LRU keyed `tenantSubscription:` so getActiveTenantSubscriptions // refetches on the next call. subscribeDisposer = await cache.subscribe((key) => { if (!key || key === "*") { clearCacheKeyPrefix("tenantSubscription:"); } else { clearCacheKey(`tenantSubscription:${key}`); } }); log.info({ namespace: "subscription" }, "subscription cache subscriber started"); return subscribeDisposer; } /** Test seam: drop the singleton + disposer so each test gets a fresh cache. */ export function __resetSubscriptionCacheForTests(): void { cacheSingleton = null; subscribeDisposer = null; }