perspectapi-ts-sdk 1.5.1 → 2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perspectapi-ts-sdk",
3
- "version": "1.5.1",
3
+ "version": "2.0.0",
4
4
  "description": "TypeScript SDK for PerspectAPI - Cloudflare Workers compatible",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -0,0 +1,285 @@
1
+ /**
2
+ * CacheManager orchestrates cache get/set/invalidate behaviour using a pluggable adapter.
3
+ */
4
+
5
+ import { InMemoryCacheAdapter } from './in-memory-adapter';
6
+ import { NoopCacheAdapter } from './noop-adapter';
7
+ import type {
8
+ CacheAdapter,
9
+ CacheConfig,
10
+ CacheEntry,
11
+ CacheInvalidateOptions,
12
+ CacheKeyPart,
13
+ CachePolicy,
14
+ CacheSetOptions,
15
+ CacheManagerOptions,
16
+ } from './types';
17
+
18
+ const TAG_PREFIX = '__tag__';
19
+
20
+ export class CacheManager {
21
+ private adapter: CacheAdapter;
22
+ private readonly defaultTtlSeconds: number;
23
+ private readonly keyPrefix: string;
24
+ private readonly enabled: boolean;
25
+
26
+ constructor(config?: CacheConfig) {
27
+ const defaultOptions: CacheManagerOptions = {
28
+ defaultTtlSeconds: 300,
29
+ keyPrefix: 'perspectapi',
30
+ };
31
+
32
+ const mergedConfig = {
33
+ ...defaultOptions,
34
+ ...config,
35
+ };
36
+
37
+ if (config && config.enabled === false) {
38
+ this.enabled = false;
39
+ this.adapter = new NoopCacheAdapter();
40
+ } else if (config && config.adapter) {
41
+ this.enabled = true;
42
+ this.adapter = config.adapter;
43
+ } else if (config) {
44
+ // Cache config supplied but no adapter; fall back to in-memory caching.
45
+ this.enabled = true;
46
+ this.adapter = new InMemoryCacheAdapter();
47
+ } else {
48
+ this.enabled = false;
49
+ this.adapter = new NoopCacheAdapter();
50
+ }
51
+
52
+ this.defaultTtlSeconds = mergedConfig.defaultTtlSeconds ?? 300;
53
+ this.keyPrefix = mergedConfig.keyPrefix ?? 'perspectapi';
54
+ }
55
+
56
+ isEnabled(): boolean {
57
+ return this.enabled;
58
+ }
59
+
60
+ getKeyPrefix(): string {
61
+ return this.keyPrefix;
62
+ }
63
+
64
+ buildKey(parts: CacheKeyPart[]): string {
65
+ const normalized = parts
66
+ .flatMap(part => this.normalizeKeyPart(part))
67
+ .filter(part => part !== undefined && part !== null && part !== '');
68
+
69
+ return normalized.join(':');
70
+ }
71
+
72
+ async getOrSet<T>(
73
+ key: string,
74
+ resolveValue: () => Promise<T>,
75
+ policy?: CachePolicy
76
+ ): Promise<T> {
77
+ if (!this.enabled || policy?.skipCache) {
78
+ const value = await resolveValue();
79
+
80
+ if (this.enabled && !policy?.skipCache && policy?.ttlSeconds !== 0) {
81
+ await this.set(key, value, policy);
82
+ }
83
+
84
+ return value;
85
+ }
86
+
87
+ const namespacedKey = this.namespacedKey(key);
88
+ const cachedRaw = await this.adapter.get(namespacedKey);
89
+
90
+ if (cachedRaw) {
91
+ const entry = this.deserialize<T>(cachedRaw);
92
+
93
+ if (!entry.expiresAt || entry.expiresAt > Date.now()) {
94
+ return entry.value;
95
+ }
96
+
97
+ // Expired entry
98
+ await this.adapter.delete(namespacedKey);
99
+ if (entry.tags?.length) {
100
+ await this.removeKeyFromTags(namespacedKey, entry.tags);
101
+ }
102
+ }
103
+
104
+ const value = await resolveValue();
105
+ await this.set(key, value, policy);
106
+
107
+ return value;
108
+ }
109
+
110
+ async set<T>(key: string, value: T, options?: CacheSetOptions): Promise<void> {
111
+ if (!this.enabled || options?.ttlSeconds === 0) {
112
+ return;
113
+ }
114
+
115
+ const namespacedKey = this.namespacedKey(key);
116
+ const ttlSeconds = options?.ttlSeconds ?? this.defaultTtlSeconds;
117
+
118
+ const entry: CacheEntry<T> = {
119
+ value,
120
+ expiresAt: ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : undefined,
121
+ tags: options?.tags,
122
+ metadata: options?.metadata,
123
+ };
124
+
125
+ await this.adapter.set(namespacedKey, this.serialize(entry), {
126
+ ttlSeconds: ttlSeconds > 0 ? ttlSeconds : undefined,
127
+ });
128
+
129
+ if (options?.tags?.length) {
130
+ await this.registerKeyTags(namespacedKey, options.tags);
131
+ }
132
+ }
133
+
134
+ async delete(key: string): Promise<void> {
135
+ if (!this.enabled) {
136
+ return;
137
+ }
138
+ const namespacedKey = this.namespacedKey(key);
139
+ await this.adapter.delete(namespacedKey);
140
+ }
141
+
142
+ async invalidate(options: CacheInvalidateOptions): Promise<void> {
143
+ if (!this.enabled) {
144
+ return;
145
+ }
146
+
147
+ if (options.keys?.length) {
148
+ const namespacedKeys = options.keys.map(key => this.namespacedKey(key));
149
+ if (this.adapter.deleteMany) {
150
+ await this.adapter.deleteMany(namespacedKeys);
151
+ } else {
152
+ await Promise.all(namespacedKeys.map(key => this.adapter.delete(key)));
153
+ }
154
+ }
155
+
156
+ if (options.tags?.length) {
157
+ await Promise.all(
158
+ options.tags.map(async tag => {
159
+ const tagKey = this.tagKey(tag);
160
+ const payload = await this.adapter.get(tagKey);
161
+ if (!payload) {
162
+ return;
163
+ }
164
+
165
+ const keys = this.deserializeTagSet(payload);
166
+ if (keys.length) {
167
+ if (this.adapter.deleteMany) {
168
+ await this.adapter.deleteMany(keys);
169
+ } else {
170
+ await Promise.all(keys.map(key => this.adapter.delete(key)));
171
+ }
172
+ }
173
+
174
+ await this.adapter.delete(tagKey);
175
+ })
176
+ );
177
+ }
178
+ }
179
+
180
+ private namespacedKey(key: string): string {
181
+ return `${this.keyPrefix}:${key}`;
182
+ }
183
+
184
+ private tagKey(tag: string): string {
185
+ return this.namespacedKey(`${TAG_PREFIX}:${tag}`);
186
+ }
187
+
188
+ private serialize(entry: CacheEntry): string {
189
+ return JSON.stringify(entry);
190
+ }
191
+
192
+ private deserialize<T>(payload: string): CacheEntry<T> {
193
+ try {
194
+ return JSON.parse(payload) as CacheEntry<T>;
195
+ } catch {
196
+ return { value: payload as unknown as T };
197
+ }
198
+ }
199
+
200
+ private deserializeTagSet(payload: string): string[] {
201
+ try {
202
+ const parsed = JSON.parse(payload);
203
+ return Array.isArray(parsed) ? (parsed as string[]) : [];
204
+ } catch {
205
+ return [];
206
+ }
207
+ }
208
+
209
+ private async registerKeyTags(namespacedKey: string, tags: string[]): Promise<void> {
210
+ await Promise.all(
211
+ tags.map(async tag => {
212
+ const tagKey = this.tagKey(tag);
213
+ const existingPayload = await this.adapter.get(tagKey);
214
+ const keys = existingPayload ? this.deserializeTagSet(existingPayload) : [];
215
+
216
+ if (!keys.includes(namespacedKey)) {
217
+ keys.push(namespacedKey);
218
+ await this.adapter.set(tagKey, JSON.stringify(keys));
219
+ }
220
+ })
221
+ );
222
+ }
223
+
224
+ private async removeKeyFromTags(namespacedKey: string, tags: string[]): Promise<void> {
225
+ await Promise.all(
226
+ tags.map(async tag => {
227
+ const tagKey = this.tagKey(tag);
228
+ const existingPayload = await this.adapter.get(tagKey);
229
+ if (!existingPayload) {
230
+ return;
231
+ }
232
+
233
+ const keys = this.deserializeTagSet(existingPayload);
234
+ const updated = keys.filter(key => key !== namespacedKey);
235
+ if (updated.length === 0) {
236
+ await this.adapter.delete(tagKey);
237
+ } else if (updated.length !== keys.length) {
238
+ await this.adapter.set(tagKey, JSON.stringify(updated));
239
+ }
240
+ })
241
+ );
242
+ }
243
+
244
+ private normalizeKeyPart(part: CacheKeyPart): string[] {
245
+ if (part === undefined || part === null || part === '') {
246
+ return [];
247
+ }
248
+
249
+ if (Array.isArray(part)) {
250
+ return part.flatMap(item => this.normalizeKeyPart(item as CacheKeyPart));
251
+ }
252
+
253
+ if (typeof part === 'object') {
254
+ return [this.normalizeObject(part as Record<string, unknown>)];
255
+ }
256
+
257
+ return [String(part)];
258
+ }
259
+
260
+ private normalizeObject(input: Record<string, unknown>): string {
261
+ const sortedKeys = Object.keys(input).sort();
262
+ const normalized: Record<string, unknown> = {};
263
+
264
+ for (const key of sortedKeys) {
265
+ const value = (input as Record<string, unknown>)[key];
266
+ if (value === undefined) {
267
+ continue;
268
+ }
269
+
270
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
271
+ normalized[key] = JSON.parse(this.normalizeObject(value as Record<string, unknown>));
272
+ } else if (Array.isArray(value)) {
273
+ normalized[key] = value.map(item =>
274
+ typeof item === 'object' && item !== null
275
+ ? JSON.parse(this.normalizeObject(item as Record<string, unknown>))
276
+ : item
277
+ );
278
+ } else {
279
+ normalized[key] = value;
280
+ }
281
+ }
282
+
283
+ return JSON.stringify(normalized);
284
+ }
285
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Simple in-memory cache adapter primarily suited for development and testing.
3
+ */
4
+
5
+ import type { CacheAdapter } from './types';
6
+
7
+ interface StoredValue {
8
+ value: string;
9
+ expiresAt?: number;
10
+ }
11
+
12
+ export class InMemoryCacheAdapter implements CacheAdapter {
13
+ private store = new Map<string, StoredValue>();
14
+
15
+ async get(key: string): Promise<string | undefined> {
16
+ const entry = this.store.get(key);
17
+ if (!entry) {
18
+ return undefined;
19
+ }
20
+
21
+ if (entry.expiresAt && entry.expiresAt <= Date.now()) {
22
+ this.store.delete(key);
23
+ return undefined;
24
+ }
25
+
26
+ return entry.value;
27
+ }
28
+
29
+ async set(key: string, value: string, options?: { ttlSeconds?: number }): Promise<void> {
30
+ const expiresAt =
31
+ options?.ttlSeconds && options.ttlSeconds > 0
32
+ ? Date.now() + options.ttlSeconds * 1000
33
+ : undefined;
34
+
35
+ this.store.set(key, { value, expiresAt });
36
+ }
37
+
38
+ async delete(key: string): Promise<void> {
39
+ this.store.delete(key);
40
+ }
41
+
42
+ async deleteMany(keys: string[]): Promise<void> {
43
+ keys.forEach(key => this.store.delete(key));
44
+ }
45
+
46
+ async clear(): Promise<void> {
47
+ this.store.clear();
48
+ }
49
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * No-op cache adapter that disables caching while preserving the cache contract.
3
+ */
4
+
5
+ import type { CacheAdapter } from './types';
6
+
7
+ export class NoopCacheAdapter implements CacheAdapter {
8
+ async get(): Promise<undefined> {
9
+ return undefined;
10
+ }
11
+
12
+ async set(): Promise<void> {
13
+ // Intentionally blank
14
+ }
15
+
16
+ async delete(): Promise<void> {
17
+ // Intentionally blank
18
+ }
19
+
20
+ async deleteMany(): Promise<void> {
21
+ // Intentionally blank
22
+ }
23
+
24
+ async clear(): Promise<void> {
25
+ // Intentionally blank
26
+ }
27
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Cache-related types for the PerspectAPI SDK.
3
+ */
4
+
5
+ export type CacheKeyPart =
6
+ | string
7
+ | number
8
+ | boolean
9
+ | null
10
+ | undefined
11
+ | Record<string, unknown>
12
+ | Array<Record<string, unknown> | string | number | boolean | null | undefined>;
13
+
14
+ export interface CacheSetOptions {
15
+ ttlSeconds?: number;
16
+ tags?: string[];
17
+ metadata?: Record<string, unknown>;
18
+ }
19
+
20
+ export interface CachePolicy extends CacheSetOptions {
21
+ skipCache?: boolean;
22
+ }
23
+
24
+ export interface CacheEntry<T = unknown> {
25
+ value: T;
26
+ expiresAt?: number;
27
+ tags?: string[];
28
+ metadata?: Record<string, unknown>;
29
+ }
30
+
31
+ export interface CacheAdapter {
32
+ /**
33
+ * Retrieve a raw cache payload for the given key. Implementations should return
34
+ * undefined (or null) when the key does not exist or has expired.
35
+ */
36
+ get(key: string): Promise<string | undefined | null>;
37
+
38
+ /**
39
+ * Store a raw payload for the given key. Implementations MAY honour ttlSeconds
40
+ * natively; the cache manager will also persist expiry timestamps in the payload
41
+ * for adapters that do not have native TTL support.
42
+ */
43
+ set(key: string, value: string, options?: { ttlSeconds?: number }): Promise<void>;
44
+
45
+ /**
46
+ * Remove a cached entry.
47
+ */
48
+ delete(key: string): Promise<void>;
49
+
50
+ /**
51
+ * Optional bulk delete implementation.
52
+ */
53
+ deleteMany?(keys: string[]): Promise<void>;
54
+
55
+ /**
56
+ * Optional clear method to wipe all entries for adapters that manage isolated namespaces.
57
+ */
58
+ clear?(): Promise<void>;
59
+ }
60
+
61
+ export interface CacheManagerOptions {
62
+ defaultTtlSeconds?: number;
63
+ keyPrefix?: string;
64
+ }
65
+
66
+ export interface CacheConfig extends CacheManagerOptions {
67
+ /**
68
+ * Explicit flag to disable caching. Defaults to false when a cache configuration
69
+ * is supplied, so caching is considered enabled unless explicitly disabled.
70
+ */
71
+ enabled?: boolean;
72
+ adapter?: CacheAdapter;
73
+ }
74
+
75
+ export interface CacheInvalidateOptions {
76
+ keys?: string[];
77
+ tags?: string[];
78
+ }
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import { BaseClient } from './base-client';
6
+ import type { CacheManager } from '../cache/cache-manager';
6
7
  import type {
7
8
  ApiKey,
8
9
  CreateApiKeyRequest,
@@ -12,8 +13,8 @@ import type {
12
13
  } from '../types';
13
14
 
14
15
  export class ApiKeysClient extends BaseClient {
15
- constructor(http: any) {
16
- super(http, '/api/v1');
16
+ constructor(http: any, cache?: CacheManager) {
17
+ super(http, '/api/v1', cache);
17
18
  }
18
19
 
19
20
  /**
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import { BaseClient } from './base-client';
6
+ import type { CacheManager } from '../cache/cache-manager';
6
7
  import type {
7
8
  SignUpRequest,
8
9
  SignInRequest,
@@ -12,8 +13,8 @@ import type {
12
13
  } from '../types';
13
14
 
14
15
  export class AuthClient extends BaseClient {
15
- constructor(http: any) {
16
- super(http, '/api/v1');
16
+ constructor(http: any, cache?: CacheManager) {
17
+ super(http, '/api/v1', cache);
17
18
  }
18
19
 
19
20
  /**
@@ -4,14 +4,18 @@
4
4
 
5
5
  import { HttpClient } from '../utils/http-client';
6
6
  import type { ApiResponse } from '../types';
7
+ import type { CacheManager } from '../cache/cache-manager';
8
+ import type { CachePolicy, CacheInvalidateOptions } from '../cache/types';
7
9
 
8
10
  export abstract class BaseClient {
9
11
  protected http: HttpClient;
10
12
  protected basePath: string;
13
+ protected cache?: CacheManager;
11
14
 
12
- constructor(http: HttpClient, basePath: string) {
15
+ constructor(http: HttpClient, basePath: string, cache?: CacheManager) {
13
16
  this.http = http;
14
17
  this.basePath = basePath;
18
+ this.cache = cache && cache.isEnabled() ? cache : undefined;
15
19
  }
16
20
 
17
21
  /**
@@ -100,4 +104,87 @@ export abstract class BaseClient {
100
104
  protected async delete<T = any>(endpoint: string, csrfToken?: string): Promise<ApiResponse<T>> {
101
105
  return this.http.delete<T>(this.buildPath(endpoint), { csrfToken });
102
106
  }
107
+
108
+ /**
109
+ * Fetch a GET endpoint with optional caching support.
110
+ */
111
+ protected async fetchWithCache<T>(
112
+ endpoint: string,
113
+ params: Record<string, any> | undefined,
114
+ tags: string[],
115
+ policy: CachePolicy | undefined,
116
+ fetcher: () => Promise<T>
117
+ ): Promise<T> {
118
+ if (!this.cache) {
119
+ return fetcher();
120
+ }
121
+
122
+ const cacheKey = this.buildCacheKey(endpoint, params);
123
+ const combinedPolicy: CachePolicy | undefined = policy
124
+ ? { ...policy, tags: this.mergeTags(tags, policy.tags) }
125
+ : { tags };
126
+
127
+ return this.cache.getOrSet<T>(cacheKey, fetcher, combinedPolicy);
128
+ }
129
+
130
+ /**
131
+ * Invalidate cache entries by keys or tags.
132
+ */
133
+ protected async invalidateCache(options: CacheInvalidateOptions): Promise<void> {
134
+ if (!this.cache) {
135
+ return;
136
+ }
137
+ await this.cache.invalidate(options);
138
+ }
139
+
140
+ /**
141
+ * Build a consistent cache key for an endpoint + params combination.
142
+ */
143
+ protected buildCacheKey(endpoint: string, params?: Record<string, any>): string {
144
+ const sanitizedEndpoint = endpoint.replace(/^\//, '');
145
+ const baseSegment = this.basePath.replace(/^\//, '');
146
+
147
+ const parts: Array<string | Record<string, unknown>> = [baseSegment, sanitizedEndpoint];
148
+
149
+ if (params && Object.keys(params).length > 0) {
150
+ parts.push(this.sortObject(params));
151
+ }
152
+
153
+ if (this.cache) {
154
+ return this.cache.buildKey(parts);
155
+ }
156
+
157
+ return parts
158
+ .map(part => (typeof part === 'string' ? part : JSON.stringify(part)))
159
+ .join(':');
160
+ }
161
+
162
+ private mergeTags(defaultTags: string[], overrideTags?: string[]): string[] {
163
+ const combined = new Set<string>();
164
+ defaultTags.forEach(tag => combined.add(tag));
165
+ overrideTags?.forEach(tag => combined.add(tag));
166
+ return Array.from(combined.values());
167
+ }
168
+
169
+ private sortObject(input: Record<string, any>): Record<string, any> {
170
+ const sortedKeys = Object.keys(input).sort();
171
+ const result: Record<string, any> = {};
172
+
173
+ for (const key of sortedKeys) {
174
+ const value = input[key];
175
+ if (value === undefined) continue;
176
+
177
+ if (Array.isArray(value)) {
178
+ result[key] = value.map(item =>
179
+ typeof item === 'object' && item !== null ? this.sortObject(item as Record<string, any>) : item
180
+ );
181
+ } else if (value && typeof value === 'object') {
182
+ result[key] = this.sortObject(value as Record<string, any>);
183
+ } else {
184
+ result[key] = value;
185
+ }
186
+ }
187
+
188
+ return result;
189
+ }
103
190
  }
@@ -3,6 +3,8 @@
3
3
  */
4
4
 
5
5
  import { BaseClient } from './base-client';
6
+ import type { CacheManager } from '../cache/cache-manager';
7
+ import type { CachePolicy } from '../cache/types';
6
8
  import type {
7
9
  Category,
8
10
  CreateCategoryRequest,
@@ -11,8 +13,8 @@ import type {
11
13
  } from '../types';
12
14
 
13
15
  export class CategoriesClient extends BaseClient {
14
- constructor(http: any) {
15
- super(http, '/api/v1');
16
+ constructor(http: any, cache?: CacheManager) {
17
+ super(http, '/api/v1', cache);
16
18
  }
17
19
 
18
20
  /**
@@ -44,10 +46,25 @@ export class CategoriesClient extends BaseClient {
44
46
  /**
45
47
  * Get product category by slug (for products)
46
48
  */
47
- async getProductCategoryBySlug(siteName: string, slug: string): Promise<ApiResponse<Category>> {
48
- return this.http.get(this.buildPath(
49
- this.siteScopedEndpoint(siteName, `/product_category/slug/${encodeURIComponent(slug)}`)
50
- ));
49
+ async getProductCategoryBySlug(
50
+ siteName: string,
51
+ slug: string,
52
+ cachePolicy?: CachePolicy
53
+ ): Promise<ApiResponse<Category>> {
54
+ const endpoint = this.siteScopedEndpoint(
55
+ siteName,
56
+ `/product_category/slug/${encodeURIComponent(slug)}`,
57
+ { includeSitesSegment: false }
58
+ );
59
+ const path = this.buildPath(endpoint);
60
+
61
+ return this.fetchWithCache<ApiResponse<Category>>(
62
+ endpoint,
63
+ undefined,
64
+ this.buildCategoryTags(siteName, slug),
65
+ cachePolicy,
66
+ () => this.http.get<Category>(path)
67
+ );
51
68
  }
52
69
 
53
70
  /**
@@ -148,4 +165,15 @@ export class CategoriesClient extends BaseClient {
148
165
  }): Promise<ApiResponse<Category[]>> {
149
166
  return this.http.get(`/categories/search`, { q: query, ...params });
150
167
  }
168
+
169
+ private buildCategoryTags(siteName: string, slug: string): string[] {
170
+ const tags = new Set<string>(['categories']);
171
+ if (siteName) {
172
+ tags.add(`categories:site:${siteName}`);
173
+ }
174
+ if (slug) {
175
+ tags.add(`categories:product:${siteName}:${slug}`);
176
+ }
177
+ return Array.from(tags.values());
178
+ }
151
179
  }
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import { BaseClient } from './base-client';
6
+ import type { CacheManager } from '../cache/cache-manager';
6
7
  import type {
7
8
  CreateCheckoutSessionRequest,
8
9
  CheckoutSession,
@@ -10,8 +11,8 @@ import type {
10
11
  } from '../types';
11
12
 
12
13
  export class CheckoutClient extends BaseClient {
13
- constructor(http: any) {
14
- super(http, '/api/v1');
14
+ constructor(http: any, cache?: CacheManager) {
15
+ super(http, '/api/v1', cache);
15
16
  }
16
17
 
17
18
  /**
@@ -52,7 +53,7 @@ export class CheckoutClient extends BaseClient {
52
53
 
53
54
  // Make the checkout request with CSRF token in headers
54
55
  return this.http.request(this.buildPath(
55
- this.siteScopedEndpoint(siteName, '/checkout/create-session')
56
+ this.siteScopedEndpoint(siteName, '/checkout/create-session', { includeSitesSegment: false })
56
57
  ), {
57
58
  method: 'POST',
58
59
  body: data,