diff --git a/.env.example b/.env.example index b66307df..07b8716a 100644 --- a/.env.example +++ b/.env.example @@ -67,8 +67,9 @@ STATIC_STORE_TOKEN= # Note that FALSE does not disable Cache Components itself, which stays on so # the app's `use cache` directives still compile — it swaps the cache # lifetime profiles for zero-second ones, so no entry is ever reused. See -# next.config.ts and the caching section of docs/ARCHITECTURE.md. -CACHE_COMPONENTS_ENABLED=TRUE +# lib/cache/cache-profiles.ts and the caching section of +# docs/ARCHITECTURE.md. +CACHE_ENABLED=TRUE # Set to "true" to log every outgoing BigCommerce API request (method, URL, # status, duration) to the console. Off by default. Since a request only diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3f64ddb6..8593acd2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -406,10 +406,12 @@ each. `MockRestApiClient` itself never changes either way. ## Caching This app uses Next's Cache Components (`cacheComponents: true`). Two -`cacheLife` profiles are configured: `standard` (5 min, most data) and -`extended` (10 min, slower-changing data like channels). +lifetime profiles are defined in `lib/cache/cache-profiles.ts`: +`standard` (5 min, most data) and `extended` (10 min, slower-changing data +like channels). Each `use cache` boundary selects one by calling +`cacheLife(cacheProfile("standard"))`. -Caching is controlled by `CACHE_COMPONENTS_ENABLED`, which `.env.example` +Caching is controlled by `CACHE_ENABLED`, which `.env.example` ships as `TRUE` so the behavior is visible out of the box. The app's own fallback when the var is undefined is *off* (see below). @@ -434,7 +436,7 @@ which is invisible to and not invalidated by `cacheTag`/`updateTag`. ### Enabling and disabling caching -Caching is controlled by `CACHE_COMPONENTS_ENABLED`, and is off unless that +Caching is controlled by `CACHE_ENABLED`, and is off unless that is explicitly `true`. Two defaults are worth keeping apart: - **The code's fallback is off.** An undefined var means no caching, so @@ -454,13 +456,13 @@ they can't be wrapped in a runtime condition (a directive nested inside an `if` is silently ignored rather than honored), and disabling `cacheComponents` outright would stop the app compiling at all. -Instead, `next.config.ts` swaps both `cacheLife` profiles for a zero-second -one (`{ stale: 0, revalidate: 0, expire: 1 }` — Next requires `expire` to +Instead, `cacheProfile()` returns a zero-second profile +(`{ stale: 0, revalidate: 0, expire: 1 }` — Next requires `expire` to exceed `revalidate`, so `1` is the floor). A `revalidate` of `0` means every entry is already expired by the time the next request tries to read it, so -nothing is ever reused and each request re-fetches. Overriding the profiles -covers every cached boundary in the app, since each one selects `standard` or -`extended`. +nothing is ever reused and each request re-fetches. Because every cached +boundary selects its profile through `cacheProfile()`, the switch covers all +of them, and call sites never have to check the variable themselves. This keeps the caching code paths intact and observable while removing the staleness: with `LOG_API_REQUESTS=true`, every page load logs its upstream diff --git a/docs/TUTORIAL.md b/docs/TUTORIAL.md index 74d41e09..dcbf2046 100644 --- a/docs/TUTORIAL.md +++ b/docs/TUTORIAL.md @@ -174,7 +174,7 @@ Completes the full set of single-click app callbacks with support for Uninstall Demonstrates Next.js caching and memoization patterns that optimize the app by avoiding repeat DB lookups and API calls. -Caching is controlled by the `CACHE_COMPONENTS_ENABLED` environment variable. +Caching is controlled by the `CACHE_ENABLED` environment variable. `.env.example` ships it as `TRUE`, so if you copied your `.env.local` from there you're already exercising the behavior this step builds — pair it with `LOG_API_REQUESTS=true` to watch cache hits and misses, since an upstream diff --git a/next.config.ts b/next.config.ts index c36e38d6..17bb846a 100644 --- a/next.config.ts +++ b/next.config.ts @@ -14,28 +14,12 @@ if (process.env.APP_ORIGIN) { allowedOrigins.push(new URL(process.env.APP_ORIGIN).host); } -// Caching is opt-in, and off unless CACHE_COMPONENTS_ENABLED is explicitly -// "true". An admin-privileged app showing stale data is usually the worse -// trade-off (see the caching section of docs/ARCHITECTURE.md), so the safe -// behavior is the default and enabling it is a deliberate choice. -function isCachingEnabled(): boolean { - return process.env.CACHE_COMPONENTS_ENABLED?.toLowerCase() === "true"; -} - -// Cache Components stays enabled either way — the `use cache` directives and -// cacheTag/updateTag calls throughout the app are compile-time constructs -// that can't be conditionally applied (a directive nested in an `if` is -// silently ignored, not honored), and turning cacheComponents off entirely -// would stop the app compiling. The lever that does work is cacheLife: a -// profile with revalidate: 0 makes every entry already-expired by the time -// the next request reads it, so nothing is ever reused and each request -// re-fetches. Next requires expire > revalidate, hence 1 rather than 0. -// -// Both configured profiles are overridden, since every `use cache` boundary -// in the app selects one of them. -const CACHE_DISABLED_PROFILE = { stale: 0, revalidate: 0, expire: 1 }; - const nextConfig: NextConfig = { + // Cache Components (PPR). The lifetime profiles each `use cache` boundary + // selects, and the CACHE_ENABLED switch that turns caching on and off, live + // in lib/cache/cache-profiles.ts rather than in a `cacheLife` block + // here — cacheLife accepts an inline profile object, so keeping them in one + // module avoids splitting the caching configuration across two places. cacheComponents: true, // Swaps the Postgres credentials-store driver for a `pg`-free stub // whenever CREDENTIALS_STORE_DRIVER isn't "POSTGRES" — see @@ -72,17 +56,6 @@ const nextConfig: NextConfig = { allowedOrigins, }, }, - cacheLife: { - // This is an admin-privileged app, so most fetches use a short lifetime — - // changes made directly in the BigCommerce control panel, or by another - // admin, shouldn't stay stale for long even where no cache tag invalidates - // them. - standard: isCachingEnabled() ? { stale: 300, revalidate: 300, expire: 300 } : CACHE_DISABLED_PROFILE, - // Channels change far less often than gift certificates or customers - // (they're a store configuration concern, not day-to-day transactional - // data), so this can tolerate a much longer lifetime. - extended: isCachingEnabled() ? { stale: 600, revalidate: 600, expire: 600 } : CACHE_DISABLED_PROFILE, - }, }; export default nextConfig; diff --git a/src/components/gift-certs-manager/customers/detail/customer-view.tsx b/src/components/gift-certs-manager/customers/detail/customer-view.tsx index bceb8533..f5e78f3c 100644 --- a/src/components/gift-certs-manager/customers/detail/customer-view.tsx +++ b/src/components/gift-certs-manager/customers/detail/customer-view.tsx @@ -1,5 +1,6 @@ import { cacheLife, cacheTag } from "next/cache"; import { notFound } from "next/navigation"; +import { cacheProfile, CACHE_PROFILE_STANDARD } from "@/lib/cache/cache-profiles"; import { Box, Flex, Panel } from "@bigcommerce/big-design"; import { ArrowBackIcon } from "@bigcommerce/big-design-icons"; import { AppLink } from "@/components/ui/app-link"; @@ -27,7 +28,7 @@ export async function CustomerView({ storeHash: string | undefined; }) { "use cache: remote"; - cacheLife("standard"); + cacheLife(cacheProfile(CACHE_PROFILE_STANDARD)); cacheTag(customerTag(id)); cacheTag(GIFT_CERTIFICATES_LIST_TAG); diff --git a/src/components/gift-certs-manager/customers/list/customer-list-view.tsx b/src/components/gift-certs-manager/customers/list/customer-list-view.tsx index 09634f76..8f3c7193 100644 --- a/src/components/gift-certs-manager/customers/list/customer-list-view.tsx +++ b/src/components/gift-certs-manager/customers/list/customer-list-view.tsx @@ -1,4 +1,5 @@ import { cacheLife, cacheTag } from "next/cache"; +import { cacheProfile, CACHE_PROFILE_STANDARD } from "@/lib/cache/cache-profiles"; import { Box, Panel } from "@bigcommerce/big-design"; import { ControlPanelLink } from "@/components/ui/control-panel-link"; import { CustomerTable } from "@/components/gift-certs-manager/customers/list/customer-table"; @@ -22,7 +23,7 @@ export async function CustomerListView({ storeHash: string | undefined; }) { "use cache: remote"; - cacheLife("standard"); + cacheLife(cacheProfile(CACHE_PROFILE_STANDARD)); cacheTag(CUSTOMERS_LIST_TAG); const query = parseCustomersQuery(searchParams); diff --git a/src/components/gift-certs-manager/gift-certificates/detail/gift-certificate-view.tsx b/src/components/gift-certs-manager/gift-certificates/detail/gift-certificate-view.tsx index 633424d2..bacddbd6 100644 --- a/src/components/gift-certs-manager/gift-certificates/detail/gift-certificate-view.tsx +++ b/src/components/gift-certs-manager/gift-certificates/detail/gift-certificate-view.tsx @@ -1,5 +1,6 @@ import { cacheLife, cacheTag } from "next/cache"; import { notFound } from "next/navigation"; +import { cacheProfile, CACHE_PROFILE_STANDARD } from "@/lib/cache/cache-profiles"; import { Box, Flex } from "@bigcommerce/big-design"; import { ArrowBackIcon } from "@bigcommerce/big-design-icons"; import { AppLink } from "@/components/ui/app-link"; @@ -21,7 +22,7 @@ export async function GiftCertificateView({ storeHash: string | undefined; }) { "use cache: remote"; - cacheLife("standard"); + cacheLife(cacheProfile(CACHE_PROFILE_STANDARD)); cacheTag(giftCertificateTag(id)); // A missing id is a real 404 from BigCommerce's v2 single-resource diff --git a/src/components/gift-certs-manager/gift-certificates/list/gift-certificate-list-view.tsx b/src/components/gift-certs-manager/gift-certificates/list/gift-certificate-list-view.tsx index f6a36206..70c102c0 100644 --- a/src/components/gift-certs-manager/gift-certificates/list/gift-certificate-list-view.tsx +++ b/src/components/gift-certs-manager/gift-certificates/list/gift-certificate-list-view.tsx @@ -1,4 +1,5 @@ import { cacheLife, cacheTag } from "next/cache"; +import { cacheProfile, CACHE_PROFILE_STANDARD } from "@/lib/cache/cache-profiles"; import { Box, Panel } from "@bigcommerce/big-design"; import { ControlPanelLink } from "@/components/ui/control-panel-link"; import { GiftCertificateTable } from "@/components/gift-certs-manager/gift-certificates/list/gift-certificate-table"; @@ -19,7 +20,7 @@ export async function GiftCertificateListView({ storeHash: string | undefined; }) { "use cache: remote"; - cacheLife("standard"); + cacheLife(cacheProfile(CACHE_PROFILE_STANDARD)); cacheTag(GIFT_CERTIFICATES_LIST_TAG); const query = parseGiftCertificatesQuery(searchParams); diff --git a/src/lib/cache/cache-profiles.ts b/src/lib/cache/cache-profiles.ts new file mode 100644 index 00000000..4f75792f --- /dev/null +++ b/src/lib/cache/cache-profiles.ts @@ -0,0 +1,59 @@ +// The app's cache lifetime profiles, and the switch that turns caching on and +// off. Every `use cache` boundary selects one by calling +// `cacheLife(cacheProfile(CACHE_PROFILE_STANDARD))`. +// + +// A cache lifetime, in seconds. Structurally compatible with Next's own +// CacheLife type, but all three fields are required: every profile here sets +// all of them, and leaving one implicit would silently inherit Next's default +// rather than this app's intent. +// +// - stale: how long a client may serve its own cached copy without rechecking. +// - revalidate: how long before the server refreshes the entry in the +// background. +// - expire: how long before the entry is treated as unusable and a read has to +// wait for fresh data. Next requires this to exceed revalidate. +export interface CacheLifetimeProfile { + stale: number; + revalidate: number; + expire: number; +} + +// Caching is opt-in, and off unless CACHE_ENABLED is explicitly "true" +function isCachingEnabled(): boolean { + return process.env.CACHE_ENABLED?.toLowerCase() === "true"; +} + +// Cache Components stays enabled either way — the `use cache` directives and +// cacheTag/updateTag calls throughout the app are compile-time constructs that +// can't be conditionally applied (a directive nested in an `if` is silently +// ignored, not honored), and turning cacheComponents off entirely would stop +// the app compiling. The lever that does work is the lifetime: a profile with +// revalidate: 0 makes every entry already-expired by the time the next request +// reads it, so nothing is ever reused and each request re-fetches. Next +// requires expire > revalidate, hence 1 rather than 0. +const CACHE_DISABLED_PROFILE: CacheLifetimeProfile = { stale: 0, revalidate: 0, expire: 1 }; + +// Profile names +export const CACHE_PROFILE_STANDARD = "standard"; +export const CACHE_PROFILE_EXTENDED = "extended"; + +// This is an admin-privileged app, so most fetches use a short lifetime — +// changes made directly in the BigCommerce control panel, or by another admin, +// shouldn't stay stale for long even where no cache tag invalidates them. +const STANDARD_PROFILE: CacheLifetimeProfile = { stale: 300, revalidate: 300, expire: 300 }; + +// For data that changes very infrequently +const EXTENDED_PROFILE: CacheLifetimeProfile = { stale: 600, revalidate: 600, expire: 600 }; + +const PROFILES = { + [CACHE_PROFILE_STANDARD]: STANDARD_PROFILE, + [CACHE_PROFILE_EXTENDED]: EXTENDED_PROFILE, +} as const satisfies Record; + +export type CacheProfile = keyof typeof PROFILES; + +// What every `use cache` boundary passes to cacheLife +export function cacheProfile(profile: CacheProfile): CacheLifetimeProfile { + return isCachingEnabled() ? PROFILES[profile] : CACHE_DISABLED_PROFILE; +} diff --git a/src/lib/gift-certs-manager/app-extension-status.ts b/src/lib/gift-certs-manager/app-extension-status.ts index 6bb72815..6e4d2f11 100644 --- a/src/lib/gift-certs-manager/app-extension-status.ts +++ b/src/lib/gift-certs-manager/app-extension-status.ts @@ -1,4 +1,5 @@ import { cacheLife, cacheTag } from "next/cache"; +import { cacheProfile, CACHE_PROFILE_EXTENDED } from "@/lib/cache/cache-profiles"; import { getCredentialsStore } from "@/lib/credentials-store/get-credentials-store"; // One shared tag per store (only one extension is ever registered). Exported @@ -9,7 +10,7 @@ export function appExtensionStatusTag(storeHash: string): string { async function fetchStoreExtensionStatus(storeHash: string): Promise<{ isRegistered: boolean }> { "use cache: remote"; - cacheLife("extended"); + cacheLife(cacheProfile(CACHE_PROFILE_EXTENDED)); cacheTag(appExtensionStatusTag(storeHash)); const extensionId = await getCredentialsStore().getStoreExtension(storeHash); diff --git a/src/lib/gift-certs-manager/channels/channels-api.ts b/src/lib/gift-certs-manager/channels/channels-api.ts index b764c3c8..755a3a1a 100644 --- a/src/lib/gift-certs-manager/channels/channels-api.ts +++ b/src/lib/gift-certs-manager/channels/channels-api.ts @@ -1,4 +1,5 @@ import { cacheLife, cacheTag } from "next/cache"; +import { cacheProfile, CACHE_PROFILE_EXTENDED } from "@/lib/cache/cache-profiles"; import { getRestApiClient } from "@/lib/bc-api-client/get-rest-api-client"; import { V3ListResponse } from "@/lib/bc-api-client/rest-client/types"; import { CHANNELS_PATH, Channel } from "@/lib/gift-certs-manager/channels/types"; @@ -13,7 +14,7 @@ export interface ChannelsResult { // "standard" lifetime the calling view uses for its own data. export async function fetchChannels(storeHash: string | undefined): Promise { "use cache: remote"; - cacheLife("extended"); + cacheLife(cacheProfile(CACHE_PROFILE_EXTENDED)); cacheTag("channels:list"); const apiClient = await getRestApiClient(storeHash); diff --git a/src/lib/gift-certs-manager/gift-certificates/gift-certificates-api.ts b/src/lib/gift-certs-manager/gift-certificates/gift-certificates-api.ts index 73e1342d..40501868 100644 --- a/src/lib/gift-certs-manager/gift-certificates/gift-certificates-api.ts +++ b/src/lib/gift-certs-manager/gift-certificates/gift-certificates-api.ts @@ -1,4 +1,5 @@ import { cacheLife, cacheTag } from "next/cache"; +import { cacheProfile, CACHE_PROFILE_STANDARD } from "@/lib/cache/cache-profiles"; import { getRestApiClient } from "@/lib/bc-api-client/get-rest-api-client"; import { giftCertificateTag, GIFT_CERTIFICATES_LIST_TAG } from "@/lib/gift-certs-manager/gift-certificates/cache-tags"; import { @@ -31,7 +32,7 @@ async function fetchGiftCertificatesPage( storeHash: string | undefined, ): Promise { "use cache: remote"; - cacheLife("standard"); + cacheLife(cacheProfile(CACHE_PROFILE_STANDARD)); cacheTag(GIFT_CERTIFICATES_LIST_TAG); const apiClient = await getRestApiClient(storeHash);