From 8f3611207e23c10d8111ddae057705741365649b Mon Sep 17 00:00:00 2001 From: John Pham Date: Fri, 14 Feb 2025 15:52:56 -0800 Subject: [PATCH] feat: Add feature flag caching mechanism --- src/helpers/feature-flags.ts | 79 ++++++++++++++++++++++++++++++++++++ src/lib/posthog.ts | 17 +++++++- src/types/deno.d.ts | 8 ++++ 3 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 src/helpers/feature-flags.ts create mode 100644 src/types/deno.d.ts diff --git a/src/helpers/feature-flags.ts b/src/helpers/feature-flags.ts new file mode 100644 index 0000000..e71ac8a --- /dev/null +++ b/src/helpers/feature-flags.ts @@ -0,0 +1,79 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; + +interface CachedFeatureFlag { + value: boolean; + expiresAt: number; +} + +interface FeatureFlagCache { + [key: string]: CachedFeatureFlag; +} + +const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +function getFeatureFlagCachePath(): string { + const configDir = join(homedir(), ".sfcompute"); + return join(configDir, "feature-flags"); +} + +export async function saveFeatureFlags(flags: FeatureFlagCache): Promise { + const cachePath = getFeatureFlagCachePath(); + const configDir = join(homedir(), ".sfcompute"); + + try { + await Deno.mkdir(configDir, { recursive: true }); + await Deno.writeTextFile(cachePath, JSON.stringify(flags, null, 2)); + } catch (error) { + console.error("boba error saving feature flags:", error); + } +} + +export async function loadFeatureFlags(): Promise { + const cachePath = getFeatureFlagCachePath(); + + try { + const cacheData = await Deno.readTextFile(cachePath); + return JSON.parse(cacheData); + } catch { + return {}; + } +} + +export async function getCachedFeatureFlag( + feature: string, + accountId: string +): Promise { + const cache = await loadFeatureFlags(); + const key = `${accountId}:${feature}`; + const cachedFlag = cache[key]; + + if (!cachedFlag) { + return null; + } + + if (Date.now() > cachedFlag.expiresAt) { + // Cache expired, remove it + delete cache[key]; + await saveFeatureFlags(cache); + return null; + } + + return cachedFlag; +} + +export async function cacheFeatureFlag( + feature: string, + accountId: string, + value: boolean +): Promise { + const cache = await loadFeatureFlags(); + const key = `${accountId}:${feature}`; + + cache[key] = { + value, + expiresAt: Date.now() + ONE_DAY_MS, + }; + + await saveFeatureFlags(cache); +} diff --git a/src/lib/posthog.ts b/src/lib/posthog.ts index 3d48f13..171a9f5 100644 --- a/src/lib/posthog.ts +++ b/src/lib/posthog.ts @@ -1,6 +1,10 @@ import process from "node:process"; import { PostHog } from "posthog-node"; import { loadConfig, saveConfig } from "../helpers/config.ts"; +import { + cacheFeatureFlag, + getCachedFeatureFlag, +} from "../helpers/feature-flags.ts"; import { getApiUrl } from "../helpers/urls.ts"; const postHogClient = new PostHog( @@ -76,12 +80,23 @@ export const isFeatureEnabled = async (feature: FeatureFlags) => { return false; } + // Check cache first + const cachedFlag = await getCachedFeatureFlag(feature, exchangeAccountId); + if (cachedFlag) { + return cachedFlag.value; + } + + // If not in cache or expired, fetch from PostHog const result = await postHogClient.isFeatureEnabled( feature, exchangeAccountId ); - return result; + // Cache the result (PostHog returns undefined if there's an error, default to false) + const finalResult = result ?? false; + await cacheFeatureFlag(feature, exchangeAccountId, finalResult); + + return finalResult; }; export const analytics = { diff --git a/src/types/deno.d.ts b/src/types/deno.d.ts new file mode 100644 index 0000000..189426e --- /dev/null +++ b/src/types/deno.d.ts @@ -0,0 +1,8 @@ +declare namespace Deno { + function writeTextFile(path: string, data: string): Promise; + function readTextFile(path: string): Promise; + function mkdir( + path: string, + options?: { recursive?: boolean } + ): Promise; +}