perspectapi-ts-sdk 2.0.0 → 2.1.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.
package/README.md CHANGED
@@ -148,6 +148,88 @@ export default {
148
148
  ```
149
149
 
150
150
  When PerspectAPI sends a webhook—or when your Worker mutates data directly—call `perspect.cache.invalidate({ tags: [...] })` using the tags emitted by the SDK (`products:site:<site>`, `content:slug:<site>:<slug>`, etc.) so stale entries are purged immediately.
151
+
152
+ ### Webhook-driven cache invalidation
153
+
154
+ Most deployments rely on PerspectAPI webhooks to bust cache entries whenever content changes. Your app needs an HTTP endpoint that:
155
+
156
+ 1. Verifies the webhook signature (or shared secret).
157
+ 2. Maps the webhook payload to the cache tags that were used when reading data.
158
+ 3. Calls `client.cache.invalidate({ tags })`.
159
+
160
+ Below is a minimal Cloudflare Worker handler; the same flow works in Express, Fastify, etc.—just swap the request parsing.
161
+
162
+ ```ts
163
+ // worker.ts
164
+ import { PerspectApiClient } from 'perspectapi-ts-sdk';
165
+
166
+ const kvAdapter = (kv: KVNamespace) => ({
167
+ get: (key: string) => kv.get(key),
168
+ set: (key: string, value: string, options?: { ttlSeconds?: number }) =>
169
+ options?.ttlSeconds
170
+ ? kv.put(key, value, { expirationTtl: options.ttlSeconds })
171
+ : kv.put(key, value),
172
+ delete: (key: string) => kv.delete(key),
173
+ });
174
+
175
+ const perspect = new PerspectApiClient({
176
+ baseUrl: env.PERSPECT_API_URL,
177
+ apiKey: env.PERSPECT_API_KEY,
178
+ cache: { adapter: kvAdapter(env.PERSPECT_CACHE), defaultTtlSeconds: 300 },
179
+ });
180
+
181
+ type WebhookEvent =
182
+ | { type: 'product.updated'; site: string; slug?: string; id?: number }
183
+ | { type: 'content.published'; site: string; slug: string; id: number }
184
+ | { type: 'category.updated'; site: string; slug: string };
185
+
186
+ const tagMap: Record<string, (e: WebhookEvent) => string[]> = {
187
+ 'product.updated': event => [
188
+ `products`,
189
+ `products:site:${event.site}`,
190
+ event.slug ? `products:slug:${event.site}:${event.slug}` : '',
191
+ event.id ? `products:id:${event.id}` : '',
192
+ ],
193
+ 'content.published': event => [
194
+ `content`,
195
+ `content:site:${event.site}`,
196
+ `content:slug:${event.site}:${event.slug}`,
197
+ `content:id:${event.id}`,
198
+ ],
199
+ 'category.updated': event => [
200
+ `categories`,
201
+ `categories:site:${event.site}`,
202
+ `categories:product:${event.site}:${event.slug}`,
203
+ ],
204
+ };
205
+
206
+ export default {
207
+ async fetch(request: Request, env: Env) {
208
+ const url = new URL(request.url);
209
+
210
+ if (url.pathname === '/webhooks/perspect' && request.method === 'POST') {
211
+ const event = (await request.json()) as WebhookEvent;
212
+
213
+ // TODO: validate shared secret / signature before proceeding
214
+
215
+ const buildTags = tagMap[event.type];
216
+ if (buildTags) {
217
+ const tags = buildTags(event).filter(Boolean);
218
+ if (tags.length) {
219
+ await perspect.cache.invalidate({ tags });
220
+ }
221
+ }
222
+
223
+ return new Response(null, { status: 202 });
224
+ }
225
+
226
+ // ...rest of your app
227
+ return new Response('ok');
228
+ },
229
+ };
230
+ ```
231
+
232
+ > 🔁 Adjust the `WebhookEvent` union and `tagMap` to match the actual payloads you receive. PerspectAPI webhooks also carry version IDs and environment metadata that you can use for more granular targeting if needed.
151
233
  ```
152
234
 
153
235
  ## Image Transformations
package/dist/index.js CHANGED
@@ -354,6 +354,7 @@ var CacheManager = class {
354
354
  }
355
355
  async getOrSet(key, resolveValue, policy) {
356
356
  if (!this.enabled || policy?.skipCache) {
357
+ console.log("[Cache] Cache disabled or skipped", { key, enabled: this.enabled, skipCache: policy?.skipCache });
357
358
  const value2 = await resolveValue();
358
359
  if (this.enabled && !policy?.skipCache && policy?.ttlSeconds !== 0) {
359
360
  await this.set(key, value2, policy);
@@ -365,12 +366,16 @@ var CacheManager = class {
365
366
  if (cachedRaw) {
366
367
  const entry = this.deserialize(cachedRaw);
367
368
  if (!entry.expiresAt || entry.expiresAt > Date.now()) {
369
+ console.log("[Cache] \u2713 HIT", { key, tags: entry.tags });
368
370
  return entry.value;
369
371
  }
372
+ console.log("[Cache] \u2717 EXPIRED", { key, expiresAt: new Date(entry.expiresAt) });
370
373
  await this.adapter.delete(namespacedKey);
371
374
  if (entry.tags?.length) {
372
375
  await this.removeKeyFromTags(namespacedKey, entry.tags);
373
376
  }
377
+ } else {
378
+ console.log("[Cache] \u2717 MISS", { key });
374
379
  }
375
380
  const value = await resolveValue();
376
381
  await this.set(key, value, policy);
@@ -388,6 +393,7 @@ var CacheManager = class {
388
393
  tags: options?.tags,
389
394
  metadata: options?.metadata
390
395
  };
396
+ console.log("[Cache] SET", { key, ttlSeconds, tags: options?.tags });
391
397
  await this.adapter.set(namespacedKey, this.serialize(entry), {
392
398
  ttlSeconds: ttlSeconds > 0 ? ttlSeconds : void 0
393
399
  });
@@ -406,34 +412,42 @@ var CacheManager = class {
406
412
  if (!this.enabled) {
407
413
  return;
408
414
  }
415
+ let totalInvalidated = 0;
409
416
  if (options.keys?.length) {
410
417
  const namespacedKeys = options.keys.map((key) => this.namespacedKey(key));
418
+ console.log("[Cache] INVALIDATE by keys", { count: options.keys.length, keys: options.keys });
411
419
  if (this.adapter.deleteMany) {
412
420
  await this.adapter.deleteMany(namespacedKeys);
413
421
  } else {
414
422
  await Promise.all(namespacedKeys.map((key) => this.adapter.delete(key)));
415
423
  }
424
+ totalInvalidated += options.keys.length;
416
425
  }
417
426
  if (options.tags?.length) {
427
+ console.log("[Cache] INVALIDATE by tags", { tags: options.tags });
418
428
  await Promise.all(
419
429
  options.tags.map(async (tag) => {
420
430
  const tagKey = this.tagKey(tag);
421
431
  const payload = await this.adapter.get(tagKey);
422
432
  if (!payload) {
433
+ console.log("[Cache] No entries for tag", { tag });
423
434
  return;
424
435
  }
425
436
  const keys = this.deserializeTagSet(payload);
426
437
  if (keys.length) {
438
+ console.log("[Cache] Invalidating entries for tag", { tag, count: keys.length });
427
439
  if (this.adapter.deleteMany) {
428
440
  await this.adapter.deleteMany(keys);
429
441
  } else {
430
442
  await Promise.all(keys.map((key) => this.adapter.delete(key)));
431
443
  }
444
+ totalInvalidated += keys.length;
432
445
  }
433
446
  await this.adapter.delete(tagKey);
434
447
  })
435
448
  );
436
449
  }
450
+ console.log("[Cache] \u2713 INVALIDATED", { totalEntries: totalInvalidated, keys: options.keys?.length || 0, tags: options.tags?.length || 0 });
437
451
  }
438
452
  namespacedKey(key) {
439
453
  return `${this.keyPrefix}:${key}`;
package/dist/index.mjs CHANGED
@@ -293,6 +293,7 @@ var CacheManager = class {
293
293
  }
294
294
  async getOrSet(key, resolveValue, policy) {
295
295
  if (!this.enabled || policy?.skipCache) {
296
+ console.log("[Cache] Cache disabled or skipped", { key, enabled: this.enabled, skipCache: policy?.skipCache });
296
297
  const value2 = await resolveValue();
297
298
  if (this.enabled && !policy?.skipCache && policy?.ttlSeconds !== 0) {
298
299
  await this.set(key, value2, policy);
@@ -304,12 +305,16 @@ var CacheManager = class {
304
305
  if (cachedRaw) {
305
306
  const entry = this.deserialize(cachedRaw);
306
307
  if (!entry.expiresAt || entry.expiresAt > Date.now()) {
308
+ console.log("[Cache] \u2713 HIT", { key, tags: entry.tags });
307
309
  return entry.value;
308
310
  }
311
+ console.log("[Cache] \u2717 EXPIRED", { key, expiresAt: new Date(entry.expiresAt) });
309
312
  await this.adapter.delete(namespacedKey);
310
313
  if (entry.tags?.length) {
311
314
  await this.removeKeyFromTags(namespacedKey, entry.tags);
312
315
  }
316
+ } else {
317
+ console.log("[Cache] \u2717 MISS", { key });
313
318
  }
314
319
  const value = await resolveValue();
315
320
  await this.set(key, value, policy);
@@ -327,6 +332,7 @@ var CacheManager = class {
327
332
  tags: options?.tags,
328
333
  metadata: options?.metadata
329
334
  };
335
+ console.log("[Cache] SET", { key, ttlSeconds, tags: options?.tags });
330
336
  await this.adapter.set(namespacedKey, this.serialize(entry), {
331
337
  ttlSeconds: ttlSeconds > 0 ? ttlSeconds : void 0
332
338
  });
@@ -345,34 +351,42 @@ var CacheManager = class {
345
351
  if (!this.enabled) {
346
352
  return;
347
353
  }
354
+ let totalInvalidated = 0;
348
355
  if (options.keys?.length) {
349
356
  const namespacedKeys = options.keys.map((key) => this.namespacedKey(key));
357
+ console.log("[Cache] INVALIDATE by keys", { count: options.keys.length, keys: options.keys });
350
358
  if (this.adapter.deleteMany) {
351
359
  await this.adapter.deleteMany(namespacedKeys);
352
360
  } else {
353
361
  await Promise.all(namespacedKeys.map((key) => this.adapter.delete(key)));
354
362
  }
363
+ totalInvalidated += options.keys.length;
355
364
  }
356
365
  if (options.tags?.length) {
366
+ console.log("[Cache] INVALIDATE by tags", { tags: options.tags });
357
367
  await Promise.all(
358
368
  options.tags.map(async (tag) => {
359
369
  const tagKey = this.tagKey(tag);
360
370
  const payload = await this.adapter.get(tagKey);
361
371
  if (!payload) {
372
+ console.log("[Cache] No entries for tag", { tag });
362
373
  return;
363
374
  }
364
375
  const keys = this.deserializeTagSet(payload);
365
376
  if (keys.length) {
377
+ console.log("[Cache] Invalidating entries for tag", { tag, count: keys.length });
366
378
  if (this.adapter.deleteMany) {
367
379
  await this.adapter.deleteMany(keys);
368
380
  } else {
369
381
  await Promise.all(keys.map((key) => this.adapter.delete(key)));
370
382
  }
383
+ totalInvalidated += keys.length;
371
384
  }
372
385
  await this.adapter.delete(tagKey);
373
386
  })
374
387
  );
375
388
  }
389
+ console.log("[Cache] \u2713 INVALIDATED", { totalEntries: totalInvalidated, keys: options.keys?.length || 0, tags: options.tags?.length || 0 });
376
390
  }
377
391
  namespacedKey(key) {
378
392
  return `${this.keyPrefix}:${key}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perspectapi-ts-sdk",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "TypeScript SDK for PerspectAPI - Cloudflare Workers compatible",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -75,6 +75,7 @@ export class CacheManager {
75
75
  policy?: CachePolicy
76
76
  ): Promise<T> {
77
77
  if (!this.enabled || policy?.skipCache) {
78
+ console.log('[Cache] Cache disabled or skipped', { key, enabled: this.enabled, skipCache: policy?.skipCache });
78
79
  const value = await resolveValue();
79
80
 
80
81
  if (this.enabled && !policy?.skipCache && policy?.ttlSeconds !== 0) {
@@ -91,14 +92,18 @@ export class CacheManager {
91
92
  const entry = this.deserialize<T>(cachedRaw);
92
93
 
93
94
  if (!entry.expiresAt || entry.expiresAt > Date.now()) {
95
+ console.log('[Cache] ✓ HIT', { key, tags: entry.tags });
94
96
  return entry.value;
95
97
  }
96
98
 
97
99
  // Expired entry
100
+ console.log('[Cache] ✗ EXPIRED', { key, expiresAt: new Date(entry.expiresAt) });
98
101
  await this.adapter.delete(namespacedKey);
99
102
  if (entry.tags?.length) {
100
103
  await this.removeKeyFromTags(namespacedKey, entry.tags);
101
104
  }
105
+ } else {
106
+ console.log('[Cache] ✗ MISS', { key });
102
107
  }
103
108
 
104
109
  const value = await resolveValue();
@@ -122,6 +127,8 @@ export class CacheManager {
122
127
  metadata: options?.metadata,
123
128
  };
124
129
 
130
+ console.log('[Cache] SET', { key, ttlSeconds, tags: options?.tags });
131
+
125
132
  await this.adapter.set(namespacedKey, this.serialize(entry), {
126
133
  ttlSeconds: ttlSeconds > 0 ? ttlSeconds : undefined,
127
134
  });
@@ -144,37 +151,47 @@ export class CacheManager {
144
151
  return;
145
152
  }
146
153
 
154
+ let totalInvalidated = 0;
155
+
147
156
  if (options.keys?.length) {
148
157
  const namespacedKeys = options.keys.map(key => this.namespacedKey(key));
158
+ console.log('[Cache] INVALIDATE by keys', { count: options.keys.length, keys: options.keys });
149
159
  if (this.adapter.deleteMany) {
150
160
  await this.adapter.deleteMany(namespacedKeys);
151
161
  } else {
152
162
  await Promise.all(namespacedKeys.map(key => this.adapter.delete(key)));
153
163
  }
164
+ totalInvalidated += options.keys.length;
154
165
  }
155
166
 
156
167
  if (options.tags?.length) {
168
+ console.log('[Cache] INVALIDATE by tags', { tags: options.tags });
157
169
  await Promise.all(
158
170
  options.tags.map(async tag => {
159
171
  const tagKey = this.tagKey(tag);
160
172
  const payload = await this.adapter.get(tagKey);
161
173
  if (!payload) {
174
+ console.log('[Cache] No entries for tag', { tag });
162
175
  return;
163
176
  }
164
177
 
165
178
  const keys = this.deserializeTagSet(payload);
166
179
  if (keys.length) {
180
+ console.log('[Cache] Invalidating entries for tag', { tag, count: keys.length });
167
181
  if (this.adapter.deleteMany) {
168
182
  await this.adapter.deleteMany(keys);
169
183
  } else {
170
184
  await Promise.all(keys.map(key => this.adapter.delete(key)));
171
185
  }
186
+ totalInvalidated += keys.length;
172
187
  }
173
188
 
174
189
  await this.adapter.delete(tagKey);
175
190
  })
176
191
  );
177
192
  }
193
+
194
+ console.log('[Cache] ✓ INVALIDATED', { totalEntries: totalInvalidated, keys: options.keys?.length || 0, tags: options.tags?.length || 0 });
178
195
  }
179
196
 
180
197
  private namespacedKey(key: string): string {