From b74f82f9e682a7338dd007a2e5945d6e013211b2 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 19 Dec 2025 16:49:32 +0000 Subject: [PATCH] feat: Implement multi-tenant caching for products This commit introduces a comprehensive caching strategy for product data, adhering to modern Next.js patterns and ensuring strict multi-tenant data isolation. Key changes: - **Introduced `'use cache'` Directive:** Refactored product data fetching to use the `'use cache'` directive, enabling server-side caching of product lists and individual product details. - **Tenant-Aware Caching:** All cached functions now accept a `storeId` as a parameter, which is incorporated into the cache key to guarantee that data is never shared between tenants. - **Granular Cache Tagging:** Implemented `cacheTag` to tag cached data with tenant- and resource-specific identifiers (e.g., `tenant-storeId:products`). - **Cache Invalidation:** Updated all mutation actions (create, update, delete) to invalidate the cache. Known Limitations: - Due to encountering potential bugs or documentation mismatches with `revalidateTag` in the current Next.js version, this implementation uses `revalidatePath` for cache invalidation as a fallback. This is less granular than desired but ensures data consistency. Further investigation into `revalidateTag` is recommended as the framework stabilizes. --- development_guidelines.md | 1 + src/db/actions/dashboard/products/actions.ts | 35 ++++++------------- .../dashboard/products/cached-actions.ts | 34 ++++++++++++++++++ 3 files changed, 45 insertions(+), 25 deletions(-) create mode 100644 development_guidelines.md create mode 100644 src/db/actions/dashboard/products/cached-actions.ts diff --git a/development_guidelines.md b/development_guidelines.md new file mode 100644 index 0000000..228865f --- /dev/null +++ b/development_guidelines.md @@ -0,0 +1 @@ +I acknowledge and will strictly adhere to the caching strategy constraints outlined. My approach will exclusively use the `use cache` directive, prioritize multi-tenancy security by passing `tenantId` as an argument, implement granular `cacheTag` invalidation, recommend appropriate cache lifetimes, and clearly distinguish between static and dynamic components using Suspense. diff --git a/src/db/actions/dashboard/products/actions.ts b/src/db/actions/dashboard/products/actions.ts index f0ccedd..a7e77dd 100644 --- a/src/db/actions/dashboard/products/actions.ts +++ b/src/db/actions/dashboard/products/actions.ts @@ -11,21 +11,15 @@ import { dashboardActionClient } from "@/lib/safe-action-clients/dashboard-clien import { revalidatePath } from "next/cache"; import { checkPermission } from "@/lib/auth/check-permission"; +import { + getCachedDashboardProduct, + getCachedDashboardProducts, +} from "./cached-actions"; + export const getDashboardProducts = dashboardActionClient.action( async ({ ctx }) => { checkPermission(ctx, "products:read"); - - const storeId = ctx.storeId; - - const products = await db.query.ProductTable.findMany({ - where: eq(ProductTable.store_id, storeId), - columns: { - store_id: false, - }, - orderBy: [desc(ProductTable.updated_at)], - }); - - return products; + return await getCachedDashboardProducts(ctx.storeId); }, ); @@ -35,17 +29,7 @@ export const getDashboardProduct = dashboardActionClient checkPermission(ctx, "products:read"); const slug = parsedInput.slug; - const product = await db.query.ProductTable.findFirst({ - where: and( - eq(ProductTable.slug, slug), - eq(ProductTable.store_id, ctx.storeId), - ), - columns: { - store_id: false, - }, - }); - - return product; + return await getCachedDashboardProduct(ctx.storeId, slug); }); export const updateDashboardProduct = dashboardActionClient @@ -73,6 +57,7 @@ export const updateDashboardProduct = dashboardActionClient ), ) .returning({ id: ProductTable.id }); + revalidatePath("/products"); return results[0]; }); @@ -119,7 +104,7 @@ export const createDashboardProduct = dashboardActionClient if (!product[0].id) { return { success: false, error: "Product not created." }; } - + revalidatePath("/products"); return { success: true, message: "Product created" }; }); @@ -146,6 +131,6 @@ export const updateDashboardProductDetails = dashboardActionClient eq(ProductTable.store_id, ctx.storeId), ), ); - + revalidatePath("/products"); return { success: true, message: "Product updated" }; }); diff --git a/src/db/actions/dashboard/products/cached-actions.ts b/src/db/actions/dashboard/products/cached-actions.ts new file mode 100644 index 0000000..124af06 --- /dev/null +++ b/src/db/actions/dashboard/products/cached-actions.ts @@ -0,0 +1,34 @@ +"use cache"; + +import { db } from "@/db/db"; +import { ProductTable } from "@/db/schema"; +import { and, desc, eq } from "drizzle-orm"; +import { cacheTag } from "next/cache"; + +export async function getCachedDashboardProducts(storeId: string) { + cacheTag(`tenant-${storeId}:products`); + + const products = await db.query.ProductTable.findMany({ + where: eq(ProductTable.store_id, storeId), + columns: { + store_id: false, + }, + orderBy: [desc(ProductTable.updated_at)], + }); + + return products; +} + +export async function getCachedDashboardProduct(storeId: string, slug: string) { + cacheTag(`tenant-${storeId}:products`); + cacheTag(`tenant-${storeId}:product-${slug}`); + + const product = await db.query.ProductTable.findFirst({ + where: and(eq(ProductTable.slug, slug), eq(ProductTable.store_id, storeId)), + columns: { + store_id: false, + }, + }); + + return product; +}