All files / lib cache-invalidation.ts

100% Statements 42/42
73.68% Branches 14/19
100% Functions 2/2
100% Lines 41/41

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125                                                                                    8x                         43x 43x 125x     43x   2x 1x 1x   2x 2x     31x 31x 31x     31x   31x 31x 31x           1x 1x 1x   1x 1x 1x     1x   1x 1x 1x     1x 1x 1x 1x 1x 1x     1x 1x 1x 1x     7x   7x 7x       43x    
// Centralized cache-invalidation contract for marketplace entities.
//
// One `invalidateEntity()` call per write replaces the scattered ad-hoc
// `cache.delete()` calls. This is the single source of truth for "what goes
// stale when X changes" — required to make 24h L2 TTLs safe (a missed
// invalidation at 24h = stale-for-a-day, vs 5 min before).
//
// Scope: KV-layer caches only — point deletes for detail keys + generation
// bumps for lists. The per-isolate enriched-project recompute stays in
// project-mutations.ts (it recomputes, it doesn't merely invalidate).
import {
	bumpGeneration,
	CACHE_KEYS,
	type DualCache,
	type GenerationEntity,
	MARKETPLACE,
} from "./cache";
 
export type EntityType =
	| "pro"
	| "project"
	| "room"
	| "blog"
	| "blogTaxonomy"
	| "locality";
 
export interface InvalidateEntityOpts {
	/** Entity id (pro id / room id). */
	id?: string;
	/** Current URL slug. */
	slug?: string;
	/** Previous slug after a rename. Its detail cache must also clear, else the
	 *  old URL serves a stale 200 instead of the 301 the SSR page now issues. */
	oldSlug?: string;
	/** Owning pro id (project / room writes). */
	proId?: string;
}
 
// pros/projects/rooms cards cross-reference each other (a project's pro name,
// a pro card's projectCount, a project's room covers), so a content write to
// any of them must bust all three listing namespaces — matching the prior
// behaviour where a project write did deletePattern() over the pros list.
const CONTENT_GENS: GenerationEntity[] = ["pros", "projects", "rooms"];
 
/**
 * Invalidate every marketplace cache affected by a write to `type`.
 *
 * Designed to be called via `c.executionCtx.waitUntil()` — non-blocking, and
 * every underlying cache op is individually non-fatal (see DualCache).
 */
export async function invalidateEntity(
	cache: DualCache,
	type: EntityType,
	opts: InvalidateEntityOpts = {},
): Promise<void> {
	const tasks: Promise<void>[] = [];
	const bump = (entities: GenerationEntity[]) => {
		for (const e of entities) tasks.push(bumpGeneration(cache, e));
	};
 
	switch (type) {
		case "pro": {
			if (opts.id) {
				tasks.push(cache.delete(MARKETPLACE.proFull(opts.id)));
				tasks.push(cache.delete(MARKETPLACE.proCard(opts.id)));
			}
			bump(CONTENT_GENS);
			break;
		}
		case "project": {
			Eif (opts.proId) {
				tasks.push(cache.delete(MARKETPLACE.proProjects(opts.proId)));
				tasks.push(cache.delete(MARKETPLACE.proFull(opts.proId)));
				// proCard embeds portfolioCovers (derived from the pro's
				// projects/rooms/media), so a project write must clear it too.
				tasks.push(cache.delete(MARKETPLACE.proCard(opts.proId)));
			}
			tasks.push(cache.delete(CACHE_KEYS.MARKETPLACE_ROOM_CATEGORIES));
			bump(CONTENT_GENS);
			break;
		}
		case "room": {
			// Room detail + card are both keyed by room id, so the id alone
			// clears them — writes that only know roomId (media uploads) stay
			// correct. (slug/oldSlug are ignored for rooms.)
			Eif (opts.id) {
				tasks.push(cache.delete(MARKETPLACE.roomDetail(opts.id)));
				tasks.push(cache.delete(MARKETPLACE.roomCard(opts.id)));
			}
			Eif (opts.proId) {
				tasks.push(cache.delete(MARKETPLACE.proProjects(opts.proId)));
				tasks.push(cache.delete(MARKETPLACE.proFull(opts.proId)));
				// proCard embeds portfolioCovers (derived from the pro's
				// projects/rooms/media), so a room write must clear it too.
				tasks.push(cache.delete(MARKETPLACE.proCard(opts.proId)));
			}
			tasks.push(cache.delete(CACHE_KEYS.MARKETPLACE_ROOM_CATEGORIES));
			bump(CONTENT_GENS);
			break;
		}
		case "blog": {
			Eif (opts.slug)
				tasks.push(cache.delete(MARKETPLACE.blogBySlug(opts.slug)));
			Eif (opts.oldSlug)
				tasks.push(cache.delete(MARKETPLACE.blogBySlug(opts.oldSlug)));
			bump(["blogs"]);
			break;
		}
		case "blogTaxonomy": {
			tasks.push(cache.delete(MARKETPLACE.blogCategories));
			tasks.push(cache.delete(MARKETPLACE.blogTags));
			bump(["blogs"]);
			break;
		}
		case "locality": {
			tasks.push(cache.delete(MARKETPLACE.localitiesAll));
			// City/locality names are embedded in pro/project/room cards.
			bump(CONTENT_GENS);
			break;
		}
	}
 
	await Promise.all(tasks);
}