perspectapi-ts-sdk 6.5.9 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +46 -1011
  2. package/dist/chunk-K3T2AFYA.mjs +1393 -0
  3. package/dist/index-CWvUyMt3.d.mts +2224 -0
  4. package/dist/index-CWvUyMt3.d.ts +2224 -0
  5. package/dist/index.d.mts +130 -2221
  6. package/dist/index.d.ts +130 -2221
  7. package/dist/index.js +8 -2
  8. package/dist/index.mjs +13 -1364
  9. package/dist/v2/index.d.mts +1 -0
  10. package/dist/v2/index.d.ts +1 -0
  11. package/dist/v2/index.js +1419 -0
  12. package/dist/v2/index.mjs +40 -0
  13. package/docs/README.md +15 -0
  14. package/docs/v1-deprecated/README.md +9 -0
  15. package/docs/v1-deprecated/examples/README.md +324 -0
  16. package/docs/v1-deprecated/examples/basic-usage.ts +258 -0
  17. package/docs/v1-deprecated/examples/cloudflare-worker.ts +274 -0
  18. package/docs/v1-deprecated/examples/content-query-with-slug-prefix.ts +237 -0
  19. package/docs/v1-deprecated/examples/image-transforms.ts +200 -0
  20. package/docs/v1-deprecated/examples/site-user-checkout.ts +186 -0
  21. package/docs/v1-deprecated/examples/slug-prefix-examples.ts +491 -0
  22. package/docs/v1-deprecated/legacy-docs/caching.md +667 -0
  23. package/docs/v1-deprecated/legacy-docs/contact.md +1396 -0
  24. package/docs/v1-deprecated/legacy-docs/csrf-protection.md +664 -0
  25. package/docs/v1-deprecated/legacy-docs/image-transforms.md +523 -0
  26. package/docs/v1-deprecated/legacy-docs/loaders.md +304 -0
  27. package/docs/v1-deprecated/legacy-docs/newsletter.md +811 -0
  28. package/docs/v1-deprecated/legacy-docs/site-users.md +817 -0
  29. package/docs/v1-deprecated/legacy-notes/CHANGELOG-CHECKOUT.md +143 -0
  30. package/docs/v1-deprecated/legacy-notes/CSRF-CHECKOUT.md +271 -0
  31. package/docs/v1-deprecated/legacy-notes/IMAGE_TRANSFORMS_PORT.md +298 -0
  32. package/docs/v1-deprecated/sdk-readme.md +1076 -0
  33. package/examples/README.md +19 -0
  34. package/examples/basic-v2.ts +37 -0
  35. package/llms.txt +25 -0
  36. package/package.json +18 -7
  37. package/src/client/api-keys-client.ts +4 -0
  38. package/src/client/auth-client.ts +4 -0
  39. package/src/client/base-client.ts +7 -0
  40. package/src/client/bundles-client.ts +4 -0
  41. package/src/client/categories-client.ts +4 -0
  42. package/src/client/checkout-client.ts +4 -0
  43. package/src/client/contact-client.ts +4 -0
  44. package/src/client/content-client.ts +4 -0
  45. package/src/client/newsletter-client.ts +4 -0
  46. package/src/client/newsletter-management-client.ts +4 -0
  47. package/src/client/organizations-client.ts +4 -0
  48. package/src/client/products-client.ts +4 -0
  49. package/src/client/site-users-client.ts +10 -1
  50. package/src/client/sites-client.ts +4 -0
  51. package/src/client/webhooks-client.ts +4 -0
  52. package/src/deprecation.ts +2 -1
  53. package/src/index.ts +2 -1
  54. package/src/loaders.ts +59 -0
  55. package/src/perspect-api-client.ts +2 -2
  56. package/src/v2/client/orders-client.ts +6 -1
  57. package/src/v2/types.ts +3 -0
@@ -0,0 +1,667 @@
1
+ # Caching and Invalidation in PerspectAPI SDK
2
+
3
+ > Deprecated v1 material. Do not copy these examples into new code. v1 sunsets
4
+ > on 2026-06-01; use `createPerspectApiV2Client` from
5
+ > `perspectapi-ts-sdk/v2` and `/api/v2`.
6
+
7
+ This document covers how to configure, use, and invalidate the cache in the PerspectAPI TypeScript SDK.
8
+
9
+ ## Configuration
10
+
11
+ Enable caching when creating the client:
12
+
13
+ ```typescript
14
+ import { PerspectApiClient } from 'perspectapi-ts-sdk';
15
+
16
+ const client = new PerspectApiClient({
17
+ baseUrl: 'https://api.perspect.commm',
18
+ apiKey: 'your-api-key',
19
+ cache: {
20
+ enabled: true,
21
+ defaultTtlSeconds: 300, // 5 minutes
22
+ keyPrefix: 'perspectapi'
23
+ }
24
+ });
25
+ ```
26
+
27
+ ### Infinite (Non-Expiring) Caching
28
+
29
+ To cache indefinitely (until you explicitly invalidate), set `defaultTtlSeconds` to `0`:
30
+
31
+ ```typescript
32
+ import { PerspectApiClient } from 'perspectapi-ts-sdk';
33
+
34
+ const client = new PerspectApiClient({
35
+ baseUrl: 'https://api.perspect.commm',
36
+ apiKey: 'your-api-key',
37
+ cache: {
38
+ enabled: true,
39
+ defaultTtlSeconds: 0,
40
+ keyPrefix: 'perspectapi'
41
+ }
42
+ });
43
+ ```
44
+
45
+ With `defaultTtlSeconds: 0`, cached entries do not expire based on time. They will remain cached until:
46
+
47
+ - **You invalidate them** via tags/keys.
48
+ - **Your underlying cache adapter evicts them** (for example due to memory limits).
49
+
50
+ ### Cache Adapters
51
+
52
+ The SDK supports pluggable cache adapters:
53
+
54
+ - **Memory adapter** (default): In-memory cache, suitable for development
55
+ - **Custom adapters**: Implement the `CacheAdapter` interface for Redis, Cloudflare KV, etc.
56
+
57
+ ## How Caching Works
58
+
59
+ ### Automatic Caching on Reads
60
+
61
+ All GET requests through the content, products, and newsletter clients are automatically cached with appropriate tags:
62
+
63
+ ```typescript
64
+ // This response is cached automatically
65
+ const content = await client.content.getContent('my-site', {
66
+ page_status: 'publish',
67
+ limit: 20
68
+ });
69
+
70
+ // Subsequent calls with the same parameters return cached data
71
+ const cachedContent = await client.content.getContent('my-site', {
72
+ page_status: 'publish',
73
+ limit: 20
74
+ });
75
+
76
+ // Newsletter list + campaign endpoints are cached too
77
+ const campaigns = await client.newsletter.getPublishedCampaigns('my-site', {
78
+ page: 1,
79
+ limit: 20
80
+ });
81
+ const sameCampaigns = await client.newsletter.getPublishedCampaigns('my-site', {
82
+ page: 1,
83
+ limit: 20
84
+ });
85
+ const campaign = await client.newsletter.getPublishedCampaignBySlug(
86
+ 'my-site',
87
+ 'spring-launch',
88
+ { slugPrefix: 'updates' }
89
+ );
90
+ ```
91
+
92
+ ### Cache Keys
93
+
94
+ Cache keys are built from:
95
+ 1. The API base path
96
+ 2. The endpoint
97
+ 3. Query parameters (sorted for consistency)
98
+
99
+ Example key: `perspectapi:api/v1:sites/my-site:{"limit":20,"page_status":"publish"}`
100
+
101
+ ### Cache Tags
102
+
103
+ Every cached entry is tagged for targeted invalidation. Tags follow a hierarchical pattern:
104
+
105
+ | Tag Pattern | Description |
106
+ |-------------|-------------|
107
+ | `content` | All content entries |
108
+ | `content:site:{siteName}` | All content for a specific site |
109
+ | `content:slug:{siteName}:{slug}` | A specific content item by slug |
110
+ | `content:id:{id}` | A specific content item by ID |
111
+ | `content:prefix:{prefix}` | All content with a specific slug prefix |
112
+ | `products` | All product entries |
113
+ | `products:site:{siteName}` | All products for a specific site |
114
+ | `products:slug:{siteName}:{slug}` | A specific product by slug |
115
+ | `products:id:{id}` | A specific product by ID |
116
+ | `products:prefix:{prefix}` | All products with a specific slug prefix |
117
+ | `products:list` | Product list queries |
118
+ | `products:category` | Category-filtered product queries |
119
+ | `products:category:{siteName}:{slug}` | Products in a specific category |
120
+ | `newsletter` | All newsletter entries |
121
+ | `newsletter:site:{siteName}` | All newsletter entries for a specific site |
122
+ | `newsletter:lists` | Newsletter list queries |
123
+ | `newsletter:lists:site:{siteName}` | Newsletter lists for a specific site |
124
+ | `newsletter:campaigns` | Published newsletter campaign queries |
125
+ | `newsletter:campaigns:list:{siteName}` | Published campaign list queries for a site |
126
+ | `newsletter:campaigns:detail:{siteName}` | Published campaign detail queries for a site |
127
+ | `newsletter:campaigns:slug:{siteName}:{slug}` | A specific published campaign by slug |
128
+ | `newsletter:campaigns:id:{siteName}:{campaignId}` | A specific published campaign by campaign ID |
129
+ | `newsletter:prefix:{prefix}` | Newsletter entries with a specific slug prefix |
130
+
131
+ ## Cache Policies
132
+
133
+ Override default caching behavior per-request:
134
+
135
+ ```typescript
136
+ // Skip cache entirely
137
+ const fresh = await client.content.getContent('my-site', params, {
138
+ skipCache: true
139
+ });
140
+
141
+ // Custom TTL
142
+ const shortLived = await client.content.getContent('my-site', params, {
143
+ ttlSeconds: 60
144
+ });
145
+
146
+ // Cache indefinitely for this request (until invalidation)
147
+ const cachedForever = await client.content.getContent('my-site', params, {
148
+ ttlSeconds: 0
149
+ });
150
+
151
+ // Add extra tags
152
+ const tagged = await client.content.getContent('my-site', params, {
153
+ tags: ['homepage', 'featured']
154
+ });
155
+ ```
156
+
157
+ ## Invalidation
158
+
159
+ ### By Tags
160
+
161
+ Invalidate all cache entries matching specific tags:
162
+
163
+ ```typescript
164
+ // Invalidate all content for a site
165
+ await cacheManager.invalidate({
166
+ tags: ['content:site:my-site']
167
+ });
168
+
169
+ // Invalidate a specific content item
170
+ await cacheManager.invalidate({
171
+ tags: ['content:slug:my-site:blog/my-post']
172
+ });
173
+
174
+ // Invalidate multiple tags at once
175
+ await cacheManager.invalidate({
176
+ tags: ['content:site:my-site', 'products:site:my-site']
177
+ });
178
+ ```
179
+
180
+ ### By Keys
181
+
182
+ Invalidate specific cache keys directly:
183
+
184
+ ```typescript
185
+ await cacheManager.invalidate({
186
+ keys: ['perspectapi:api/v1:sites/my-site:{"limit":20}']
187
+ });
188
+ ```
189
+
190
+ ### Webhook-Driven Invalidation for Newsletters
191
+
192
+ The newsletter client includes a helper that converts webhook payloads to the correct cache tags and invalidates them for you.
193
+
194
+ ```typescript
195
+ const result = await client.newsletter.invalidatePublishedCampaignCacheFromWebhook(
196
+ webhookPayload,
197
+ 'my-site' // optional fallback when payload omits site name
198
+ );
199
+
200
+ if (result.invalidated) {
201
+ console.log('Invalidated tags:', result.tags);
202
+ }
203
+ ```
204
+
205
+ `invalidatePublishedCampaignCacheFromWebhook` intentionally invalidates only publish events (for example `newsletter.published`, `newsletter_campaign.published`, or `content.published` with `origin_type = newsletter_campaign`). Create/draft updates are ignored.
206
+
207
+ ---
208
+
209
+ ## Slug Prefix Caching
210
+
211
+ ### What is a Slug Prefix?
212
+
213
+ A slug prefix is the first path segment of a hierarchical slug. For example:
214
+
215
+ | Full Slug | Prefix |
216
+ |-----------|--------|
217
+ | `blog/my-first-post` | `blog` |
218
+ | `blog/2024/annual-review` | `blog` |
219
+ | `shop/electronics/laptop` | `shop` |
220
+ | `about` | *(none)* |
221
+
222
+ Slug prefixes are commonly used to organize content by section (blog, shop, docs) or by language (en, es, fr).
223
+
224
+ ### Why Cache by Slug Prefix?
225
+
226
+ Consider a site with this content structure:
227
+
228
+ ```
229
+ /blog/post-1
230
+ /blog/post-2
231
+ /blog/post-3
232
+ /shop/product-a
233
+ /shop/product-b
234
+ /about
235
+ /contact
236
+ ```
237
+
238
+ When a blog post is updated, you need to invalidate:
239
+ 1. The individual post cache (`content:slug:my-site:blog/post-1`)
240
+ 2. Any list queries that included that post
241
+
242
+ Without prefix tagging, you'd have to either:
243
+ - Invalidate ALL content caches (too broad, poor cache hit rate)
244
+ - Track every list query that might include the post (complex and error-prone)
245
+
246
+ With prefix tagging, you can surgically invalidate just the blog section:
247
+
248
+ ```typescript
249
+ await cacheManager.invalidate({
250
+ tags: ['content:prefix:blog']
251
+ });
252
+ ```
253
+
254
+ This invalidates:
255
+ - All blog post individual page caches
256
+ - All list queries filtered by `slug_prefix: 'blog'`
257
+
258
+ But preserves:
259
+ - Shop product caches
260
+ - Other page caches (`/about`, `/contact`)
261
+ - List queries for other prefixes
262
+
263
+ ### How Prefix Tags Are Applied
264
+
265
+ #### List Queries
266
+
267
+ When fetching lists with a `slug_prefix` parameter, the cache is tagged with that prefix:
268
+
269
+ ```typescript
270
+ // Tagged with: content, content:site:my-site, content:prefix:blog
271
+ const blogPosts = await client.content.getContent('my-site', {
272
+ slug_prefix: 'blog',
273
+ page_status: 'publish'
274
+ });
275
+
276
+ // Tagged with: products, products:site:my-site, products:prefix:shop, products:list
277
+ const shopProducts = await client.products.getProducts('my-site', {
278
+ slug_prefix: 'shop'
279
+ });
280
+ ```
281
+
282
+ #### Individual Item Fetches
283
+
284
+ When fetching by slug, the prefix is automatically extracted:
285
+
286
+ ```typescript
287
+ // Tagged with: content, content:site:my-site, content:slug:my-site:blog/my-post, content:prefix:blog
288
+ const post = await client.content.getContentBySlug('my-site', 'blog/my-post');
289
+
290
+ // Tagged with: products, products:site:my-site, products:slug:my-site:shop/widget, products:prefix:shop
291
+ const product = await client.products.getProductBySlug('my-site', 'shop/widget');
292
+ ```
293
+
294
+ ### Invalidation Patterns
295
+
296
+ #### When a single item changes
297
+
298
+ ```typescript
299
+ // Invalidate the specific item AND all prefix-filtered lists
300
+ await cacheManager.invalidate({
301
+ tags: [
302
+ 'content:slug:my-site:blog/my-post', // The specific post
303
+ 'content:prefix:blog' // All blog lists and posts
304
+ ]
305
+ });
306
+ ```
307
+
308
+ #### When rebuilding a section
309
+
310
+ ```typescript
311
+ // Invalidate everything under the 'docs' prefix
312
+ await cacheManager.invalidate({
313
+ tags: ['content:prefix:docs']
314
+ });
315
+ ```
316
+
317
+ #### When a new item is added
318
+
319
+ Adding a new item to a prefix section should invalidate list queries:
320
+
321
+ ```typescript
322
+ // New blog post added - invalidate blog lists so they include the new post
323
+ await cacheManager.invalidate({
324
+ tags: ['content:prefix:blog']
325
+ });
326
+ ```
327
+
328
+ #### Site-wide content update
329
+
330
+ ```typescript
331
+ // Nuclear option: invalidate all content for a site
332
+ await cacheManager.invalidate({
333
+ tags: ['content:site:my-site']
334
+ });
335
+ ```
336
+
337
+ ### Multi-Language Sites
338
+
339
+ Slug prefixes work well for language-segmented sites:
340
+
341
+ ```typescript
342
+ // Fetch English content
343
+ const enContent = await client.content.getContent('my-site', {
344
+ slug_prefix: 'en'
345
+ });
346
+
347
+ // Fetch Spanish content
348
+ const esContent = await client.content.getContent('my-site', {
349
+ slug_prefix: 'es'
350
+ });
351
+
352
+ // Invalidate only Spanish content after translation update
353
+ await cacheManager.invalidate({
354
+ tags: ['content:prefix:es']
355
+ });
356
+ ```
357
+
358
+ ### Best Practices
359
+
360
+ 1. **Use consistent prefix conventions**: Establish a clear URL structure (`/blog/*`, `/shop/*`, `/docs/*`) and stick to it.
361
+
362
+ 2. **Invalidate by prefix when content changes**: When a CMS webhook fires for a content update, extract the prefix from the slug and invalidate that prefix tag.
363
+
364
+ 3. **Don't over-invalidate**: Use the most specific tag that covers the affected content. Prefer `content:prefix:blog` over `content:site:my-site` when only blog content changed.
365
+
366
+ 4. **Combine with item-specific tags**: For single-item updates, invalidate both the specific item and the prefix to ensure both individual fetches and list queries are refreshed.
367
+
368
+ ```typescript
369
+ // Recommended pattern for content updates
370
+ async function onContentUpdated(siteName: string, slug: string) {
371
+ const prefix = slug.includes('/') ? slug.split('/')[0] : null;
372
+
373
+ const tags = [`content:slug:${siteName}:${slug}`];
374
+ if (prefix) {
375
+ tags.push(`content:prefix:${prefix}`);
376
+ }
377
+
378
+ await cacheManager.invalidate({ tags });
379
+ }
380
+ ```
381
+
382
+ 5. **Consider prefix hierarchies**: If you have nested prefixes like `docs/api/v1/*`, the SDK tags with the first segment (`docs`). For more granular control, use custom tags via cache policies.
383
+
384
+ ---
385
+
386
+ ## Granular Caching and Invalidation
387
+
388
+ **Always prefer the most specific invalidation possible.** Over-invalidating defeats the purpose of caching and can cause unnecessary load on your API. Under-invalidating leads to stale data.
389
+
390
+ ### The Granularity Hierarchy
391
+
392
+ From most specific (preferred) to least specific (avoid when possible):
393
+
394
+ | Level | Tag Pattern | Use When |
395
+ |-------|-------------|----------|
396
+ | 1. Item | `content:slug:my-site:blog/post-1` | A single item changed |
397
+ | 2. Prefix | `content:prefix:blog` | Multiple items in a section changed |
398
+ | 3. Site | `content:site:my-site` | Site-wide changes (settings, theme) |
399
+ | 4. Global | `content` | Schema changes, migrations |
400
+
401
+ ### ❌ Anti-Pattern: Over-Invalidation
402
+
403
+ ```typescript
404
+ // BAD: Invalidates ALL content when only one post changed
405
+ async function onBlogPostUpdated(siteName: string, slug: string) {
406
+ await cacheManager.invalidate({
407
+ tags: ['content:site:my-site'] // Nukes everything!
408
+ });
409
+ }
410
+ ```
411
+
412
+ This invalidates:
413
+ - All blog posts (intended)
414
+ - All shop pages (unnecessary)
415
+ - All static pages (unnecessary)
416
+ - All list queries (unnecessary)
417
+
418
+ **Result**: Poor cache hit rate, increased API load, slower page loads.
419
+
420
+ ### ✅ Correct Pattern: Targeted Invalidation
421
+
422
+ ```typescript
423
+ // GOOD: Invalidates only what's affected
424
+ async function onBlogPostUpdated(siteName: string, slug: string) {
425
+ const prefix = slug.includes('/') ? slug.split('/')[0] : null;
426
+
427
+ const tags = [`content:slug:${siteName}:${slug}`];
428
+ if (prefix) {
429
+ tags.push(`content:prefix:${prefix}`);
430
+ }
431
+
432
+ await cacheManager.invalidate({ tags });
433
+ }
434
+ ```
435
+
436
+ This invalidates:
437
+ - The specific post (`content:slug:my-site:blog/post-1`)
438
+ - Blog list queries (`content:prefix:blog`)
439
+
440
+ **Result**: Shop pages, static pages, and other sections remain cached.
441
+
442
+ ### Real-World Invalidation Scenarios
443
+
444
+ #### Scenario 1: Single Blog Post Edit
445
+
446
+ A user edits the title of `blog/my-post`.
447
+
448
+ ```typescript
449
+ // Invalidate the post and any lists that might show it
450
+ await cacheManager.invalidate({
451
+ tags: [
452
+ 'content:slug:my-site:blog/my-post',
453
+ 'content:prefix:blog'
454
+ ]
455
+ });
456
+ ```
457
+
458
+ #### Scenario 2: New Product Added to Category
459
+
460
+ A new product is added to the "electronics" category.
461
+
462
+ ```typescript
463
+ // Invalidate category listings, not individual product pages
464
+ await cacheManager.invalidate({
465
+ tags: [
466
+ 'products:category:my-site:electronics',
467
+ 'products:prefix:shop' // If products use shop/* slugs
468
+ ]
469
+ });
470
+ ```
471
+
472
+ #### Scenario 3: Product Price Update
473
+
474
+ Only the price of a specific product changed.
475
+
476
+ ```typescript
477
+ // Invalidate just that product - lists showing price need refresh too
478
+ await cacheManager.invalidate({
479
+ tags: [
480
+ 'products:slug:my-site:shop/widget-pro',
481
+ 'products:id:prod_12345'
482
+ ]
483
+ });
484
+ ```
485
+
486
+ #### Scenario 4: Bulk Import of Blog Posts
487
+
488
+ 50 new blog posts were imported.
489
+
490
+ ```typescript
491
+ // Prefix invalidation is appropriate here
492
+ await cacheManager.invalidate({
493
+ tags: ['content:prefix:blog']
494
+ });
495
+ ```
496
+
497
+ #### Scenario 5: Site Settings Changed (Logo, Footer)
498
+
499
+ Global site settings affect all pages.
500
+
501
+ ```typescript
502
+ // Site-wide invalidation is justified
503
+ await cacheManager.invalidate({
504
+ tags: ['content:site:my-site', 'products:site:my-site']
505
+ });
506
+ ```
507
+
508
+ ### List Invalidation by Slug Prefix
509
+
510
+ When content is fetched with a `slug_prefix` parameter, the SDK automatically tags that cached response with `content:prefix:{prefix}`. This is the key mechanism for invalidating lists when new items are created.
511
+
512
+ **How it works:**
513
+
514
+ ```typescript
515
+ // This list query gets tagged with: content:prefix:blog
516
+ const blogPosts = await client.content.getContent('my-site', {
517
+ slug_prefix: 'blog',
518
+ page_status: 'publish',
519
+ limit: 10
520
+ });
521
+ ```
522
+
523
+ When a new post `blog/new-article` is created, invalidating `content:prefix:blog` will:
524
+ - Clear the cached list above (so the next fetch includes the new post)
525
+ - Clear any other cached lists filtered by `slug_prefix: 'blog'`
526
+ - Clear individual post caches under the `blog/` prefix
527
+
528
+ **Example: New content created with a slug prefix**
529
+
530
+ ```typescript
531
+ // A new blog post is created: blog/exciting-news
532
+ async function onContentCreated(siteName: string, slug: string) {
533
+ const prefix = slug.includes('/') ? slug.split('/')[0] : null;
534
+
535
+ if (prefix) {
536
+ // Invalidate ALL cached lists that used this prefix
537
+ // This ensures the new post appears in list queries
538
+ await cacheManager.invalidate({
539
+ tags: [`content:prefix:${prefix}`]
540
+ });
541
+ } else {
542
+ // Root-level content (no prefix) - must invalidate site-wide lists
543
+ await cacheManager.invalidate({
544
+ tags: [`content:site:${siteName}`]
545
+ });
546
+ }
547
+ }
548
+
549
+ // When blog/exciting-news is created:
550
+ // - Invalidates: content:prefix:blog
551
+ // - Clears: all cached getContent() calls with slug_prefix: 'blog'
552
+ // - Preserves: shop/* caches, docs/* caches, individual non-blog pages
553
+ ```
554
+
555
+ **Why this matters:**
556
+
557
+ Without prefix-based list invalidation, you'd face a dilemma:
558
+ 1. **Over-invalidate**: Clear all site content caches (wasteful)
559
+ 2. **Under-invalidate**: Only clear the new item's cache (lists show stale data, missing the new post)
560
+
561
+ The prefix tag gives you the middle ground: invalidate only the lists that could contain the new item.
562
+
563
+ ### Using Custom Tags for Fine-Grained Control
564
+
565
+ When built-in tags aren't granular enough, add custom tags:
566
+
567
+ ```typescript
568
+ // Tag featured content separately
569
+ const featured = await client.content.getContent('my-site',
570
+ { page_status: 'publish', limit: 5 },
571
+ { tags: ['homepage:featured'] }
572
+ );
573
+
574
+ // Tag sidebar content
575
+ const sidebar = await client.content.getContent('my-site',
576
+ { slug_prefix: 'widgets' },
577
+ { tags: ['layout:sidebar'] }
578
+ );
579
+
580
+ // Invalidate only homepage featured section
581
+ await cacheManager.invalidate({
582
+ tags: ['homepage:featured']
583
+ });
584
+ ```
585
+
586
+ ### Webhook Integration Example
587
+
588
+ Here's a complete example for handling CMS webhooks with granular invalidation:
589
+
590
+ ```typescript
591
+ import { CacheManager } from 'perspectapi-ts-sdk';
592
+
593
+ interface WebhookPayload {
594
+ event: 'content.created' | 'content.updated' | 'content.deleted' | 'content.bulk_update';
595
+ site: string;
596
+ slug?: string;
597
+ id?: string;
598
+ prefix?: string; // For bulk operations
599
+ }
600
+
601
+ async function handleWebhook(payload: WebhookPayload, cacheManager: CacheManager) {
602
+ const { event, site, slug, id, prefix } = payload;
603
+
604
+ switch (event) {
605
+ case 'content.created':
606
+ case 'content.deleted':
607
+ // New/deleted items affect list queries
608
+ if (slug) {
609
+ const slugPrefix = slug.includes('/') ? slug.split('/')[0] : null;
610
+ await cacheManager.invalidate({
611
+ tags: slugPrefix
612
+ ? [`content:prefix:${slugPrefix}`]
613
+ : [`content:site:${site}`] // Fallback for root-level content
614
+ });
615
+ }
616
+ break;
617
+
618
+ case 'content.updated':
619
+ // Updated items: invalidate specific item + relevant lists
620
+ const tags: string[] = [];
621
+ if (slug) {
622
+ tags.push(`content:slug:${site}:${slug}`);
623
+ const slugPrefix = slug.includes('/') ? slug.split('/')[0] : null;
624
+ if (slugPrefix) {
625
+ tags.push(`content:prefix:${slugPrefix}`);
626
+ }
627
+ }
628
+ if (id) {
629
+ tags.push(`content:id:${id}`);
630
+ }
631
+ if (tags.length > 0) {
632
+ await cacheManager.invalidate({ tags });
633
+ }
634
+ break;
635
+
636
+ case 'content.bulk_update':
637
+ // Bulk operations: use prefix if provided, otherwise site-wide
638
+ await cacheManager.invalidate({
639
+ tags: prefix
640
+ ? [`content:prefix:${prefix}`]
641
+ : [`content:site:${site}`]
642
+ });
643
+ break;
644
+ }
645
+ }
646
+ ```
647
+
648
+ ### Monitoring Cache Effectiveness
649
+
650
+ Track these metrics to ensure your invalidation strategy is working:
651
+
652
+ 1. **Cache hit rate**: Should be >80% for read-heavy sites
653
+ 2. **Invalidation frequency**: Spikes may indicate over-invalidation
654
+ 3. **Stale content reports**: May indicate under-invalidation
655
+
656
+ ```typescript
657
+ // Example: Log invalidation events for monitoring
658
+ async function invalidateWithLogging(
659
+ cacheManager: CacheManager,
660
+ tags: string[]
661
+ ) {
662
+ console.log(`[Cache] Invalidating tags: ${tags.join(', ')}`);
663
+ const start = Date.now();
664
+ await cacheManager.invalidate({ tags });
665
+ console.log(`[Cache] Invalidation completed in ${Date.now() - start}ms`);
666
+ }
667
+ ```