@oxyhq/core 3.8.1 → 3.9.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 (55) hide show
  1. package/README.md +10 -0
  2. package/dist/cjs/.tsbuildinfo +1 -1
  3. package/dist/cjs/HttpService.js +18 -4
  4. package/dist/cjs/OxyServices.base.js +15 -1
  5. package/dist/cjs/mixins/OxyServices.applications.js +69 -6
  6. package/dist/cjs/mixins/OxyServices.assets.js +16 -3
  7. package/dist/cjs/mixins/OxyServices.features.js +47 -10
  8. package/dist/cjs/mixins/OxyServices.managedAccounts.js +29 -3
  9. package/dist/cjs/mixins/OxyServices.privacy.js +34 -8
  10. package/dist/cjs/mixins/OxyServices.topics.js +5 -1
  11. package/dist/cjs/mixins/OxyServices.user.js +11 -2
  12. package/dist/cjs/mixins/OxyServices.workspaces.js +38 -3
  13. package/dist/cjs/utils/cache.js +9 -2
  14. package/dist/esm/.tsbuildinfo +1 -1
  15. package/dist/esm/HttpService.js +18 -4
  16. package/dist/esm/OxyServices.base.js +15 -1
  17. package/dist/esm/mixins/OxyServices.applications.js +69 -6
  18. package/dist/esm/mixins/OxyServices.assets.js +16 -3
  19. package/dist/esm/mixins/OxyServices.features.js +47 -10
  20. package/dist/esm/mixins/OxyServices.managedAccounts.js +29 -3
  21. package/dist/esm/mixins/OxyServices.privacy.js +34 -8
  22. package/dist/esm/mixins/OxyServices.topics.js +5 -1
  23. package/dist/esm/mixins/OxyServices.user.js +11 -2
  24. package/dist/esm/mixins/OxyServices.workspaces.js +38 -3
  25. package/dist/esm/utils/cache.js +9 -2
  26. package/dist/types/.tsbuildinfo +1 -1
  27. package/dist/types/HttpService.d.ts +9 -0
  28. package/dist/types/OxyServices.base.d.ts +12 -0
  29. package/dist/types/mixins/OxyServices.applications.d.ts +26 -0
  30. package/dist/types/mixins/OxyServices.features.d.ts +27 -6
  31. package/dist/types/mixins/OxyServices.managedAccounts.d.ts +16 -1
  32. package/dist/types/mixins/OxyServices.privacy.d.ts +22 -4
  33. package/dist/types/mixins/OxyServices.user.d.ts +8 -1
  34. package/dist/types/mixins/OxyServices.workspaces.d.ts +12 -0
  35. package/dist/types/models/interfaces.d.ts +12 -0
  36. package/dist/types/utils/cache.d.ts +4 -1
  37. package/package.json +1 -4
  38. package/src/HttpService.ts +28 -4
  39. package/src/OxyServices.base.ts +15 -1
  40. package/src/__tests__/httpServiceCache.test.ts +68 -0
  41. package/src/__tests__/linkedClient.test.ts +61 -0
  42. package/src/mixins/OxyServices.applications.ts +71 -6
  43. package/src/mixins/OxyServices.assets.ts +16 -3
  44. package/src/mixins/OxyServices.features.ts +47 -10
  45. package/src/mixins/OxyServices.managedAccounts.ts +29 -3
  46. package/src/mixins/OxyServices.privacy.ts +34 -8
  47. package/src/mixins/OxyServices.topics.ts +5 -1
  48. package/src/mixins/OxyServices.user.ts +11 -2
  49. package/src/mixins/OxyServices.workspaces.ts +39 -3
  50. package/src/mixins/__tests__/privacyCacheInvalidation.test.ts +147 -0
  51. package/src/models/interfaces.ts +13 -1
  52. package/src/utils/cache.ts +9 -2
  53. package/dist/cjs/mixins/OxyServices.popup.js +0 -263
  54. package/dist/esm/mixins/OxyServices.popup.js +0 -261
  55. package/dist/types/mixins/OxyServices.popup.d.ts +0 -170
@@ -46,6 +46,15 @@ export declare class HttpService {
46
46
  private baseURL;
47
47
  private tokenStore;
48
48
  private cache;
49
+ /**
50
+ * When true, the per-instance GET response cache is OFF: GET responses are
51
+ * never read from nor written to {@link cache}, so every request hits the
52
+ * network. Set from `config.enableCache === false` OR `config.cacheTTL <= 0`.
53
+ * A disabled instance does not register its cache for the global cleanup
54
+ * interval (nothing ever lands in it). Request deduplication is unaffected —
55
+ * concurrent identical in-flight requests still collapse into one.
56
+ */
57
+ private readonly cacheDisabled;
49
58
  private deduplicator;
50
59
  private requestQueue;
51
60
  private logger;
@@ -52,6 +52,18 @@ export declare class OxyServicesBase {
52
52
  * OxyServices instance mounted in OxyProvider. The returned client has its own
53
53
  * base URL, cache and request queue, but its bearer token is kept in lockstep
54
54
  * with this session and its 401 refresh path delegates back to this session.
55
+ *
56
+ * **GET response caching is OFF by default for linked clients.** The SDK's
57
+ * per-instance GET cache is only safe where the SDK OWNS invalidation: on the
58
+ * canonical OxyServices client, every mutation (`updateProfile`, `followUser`,
59
+ * `blockUser`, …) busts the matching cached GET. A linked client targets the
60
+ * consuming app's OWN backend (`api.mention.earth`, `api.syra.fm`, …), whose
61
+ * resources and write endpoints the SDK has no knowledge of — so it cannot
62
+ * invalidate them, and a cached GET there would silently serve stale data
63
+ * after the app mutates its own data. Caching is therefore unsafe-by-construction
64
+ * here and is left to the consumer's own layer (React Query / stores), which
65
+ * owns its invalidation. Pass `createLinkedClient({ baseURL, enableCache: true })`
66
+ * to explicitly opt back in when the consumer accepts that responsibility.
55
67
  */
56
68
  createLinkedClient(config: OxyConfig): LinkedHttpClient;
57
69
  /**
@@ -363,6 +363,32 @@ export declare function OxyServicesApplicationsMixin<T extends typeof OxyService
363
363
  * @param period - Time window (defaults to the server default).
364
364
  */
365
365
  getApplicationUsage(applicationId: string, period?: ApplicationUsagePeriod): Promise<ApplicationUsageStats>;
366
+ /**
367
+ * Bust every cached application list. `getApplications(workspaceId?)` keys
368
+ * the unscoped list as `GET:/applications` and each workspace-scoped list as
369
+ * `GET:/applications?workspaceId=<id>` (the query string is part of the URL
370
+ * path). A change to list membership (create/delete/ownership transfer)
371
+ * invalidates all of them, so we clear the unscoped entry plus every
372
+ * `?workspaceId=` variant via a prefix sweep. The prefix `GET:/applications?`
373
+ * matches only the query-string list variants, never the `GET:/applications/<id>…`
374
+ * detail/sub-resource keys.
375
+ *
376
+ * Internal helper (leading underscore); not part of the supported public
377
+ * surface. Public rather than `private` because mixins compose into an
378
+ * exported anonymous class, where TypeScript cannot represent a private
379
+ * member in the emitted declaration file (TS4094).
380
+ */
381
+ _invalidateApplicationLists(): void;
382
+ /**
383
+ * Bust the cached member list and detail for an application after a
384
+ * membership mutation. The member list (`getApplicationMembers`) and the
385
+ * detail (`getApplication`, which can embed member counts) both go stale
386
+ * when the member set or a member's role changes.
387
+ *
388
+ * Internal helper (leading underscore); see `_invalidateApplicationLists`
389
+ * for why this is public rather than `private`.
390
+ */
391
+ _invalidateApplicationMembership(applicationId: string): void;
366
392
  httpService: import("../HttpService").HttpService;
367
393
  cloudURL: string;
368
394
  config: import("../OxyServices.base").OxyConfig;
@@ -129,19 +129,33 @@ export declare function OxyServicesFeaturesMixin<T extends typeof OxyServicesBas
129
129
  */
130
130
  getCollections(userId?: string): Promise<Collection[]>;
131
131
  /**
132
- * Save an item
132
+ * Save an item.
133
+ *
134
+ * Busts the cached own saved-items list (`GET /saves`, ~short TTL) so a
135
+ * follow-up `getSavedItems()` observes the new item. The `userId`-scoped
136
+ * variant (`/users/<id>/saves`) is another user's list and is not
137
+ * affected by the caller's own save.
133
138
  */
134
139
  saveItem(itemId: string, itemType: string, collectionId?: string): Promise<SavedItem>;
135
140
  /**
136
- * Remove an item from saves
141
+ * Remove an item from saves.
142
+ *
143
+ * Busts the cached own saved-items list so the removed item is gone on
144
+ * the next read (see `saveItem`).
137
145
  */
138
146
  removeSavedItem(saveId: string): Promise<void>;
139
147
  /**
140
- * Create a collection
148
+ * Create a collection.
149
+ *
150
+ * Busts the cached own collections list (`GET /collections`) so the new
151
+ * collection appears on the next read.
141
152
  */
142
153
  createCollection(name: string, description?: string): Promise<Collection>;
143
154
  /**
144
- * Delete a collection
155
+ * Delete a collection.
156
+ *
157
+ * Busts the cached own collections list so the deleted collection is
158
+ * gone on the next read (see `createCollection`).
145
159
  */
146
160
  deleteCollection(collectionId: string): Promise<void>;
147
161
  /**
@@ -153,11 +167,18 @@ export declare function OxyServicesFeaturesMixin<T extends typeof OxyServicesBas
153
167
  */
154
168
  getUserHistory(userId?: string, limit?: number, offset?: number): Promise<HistoryItem[]>;
155
169
  /**
156
- * Clear user history
170
+ * Clear user history.
171
+ *
172
+ * `getUserHistory` caches per (limit, offset) page, so its cache key
173
+ * carries serialized params (`GET:/history` and `GET:/history:<params>`).
174
+ * A prefix sweep busts every cached page of the own history at once.
157
175
  */
158
176
  clearUserHistory(): Promise<void>;
159
177
  /**
160
- * Delete a history item
178
+ * Delete a history item.
179
+ *
180
+ * Busts every cached page of the own history (see `clearUserHistory`)
181
+ * so the removed item no longer appears on the next read.
161
182
  */
162
183
  deleteHistoryItem(itemId: string): Promise<void>;
163
184
  /**
@@ -36,7 +36,9 @@ export declare function OxyServicesManagedAccountsMixin<T extends typeof OxyServ
36
36
  * Create a new managed account (sub-account).
37
37
  *
38
38
  * The server creates a User document with `isManagedAccount: true` and links
39
- * it to the authenticated user as owner.
39
+ * it to the authenticated user as owner. Invalidates the cached
40
+ * `GET /managed-accounts` list (~2-minute TTL, identity-scoped) so the next
41
+ * read includes the newly created account.
40
42
  */
41
43
  createManagedAccount(data: CreateManagedAccountInput): Promise<ManagedAccount>;
42
44
  /**
@@ -50,17 +52,27 @@ export declare function OxyServicesManagedAccountsMixin<T extends typeof OxyServ
50
52
  /**
51
53
  * Update a managed account's profile data.
52
54
  * Requires owner or admin role.
55
+ *
56
+ * Invalidates both the cached detail (`GET /managed-accounts/<id>`) and the
57
+ * cached list (`GET /managed-accounts`, which embeds account profile data)
58
+ * so neither serves the pre-update snapshot within their ~2-minute TTL.
53
59
  */
54
60
  updateManagedAccount(accountId: string, data: Partial<CreateManagedAccountInput>): Promise<ManagedAccount>;
55
61
  /**
56
62
  * Delete a managed account permanently.
57
63
  * Requires owner role.
64
+ *
65
+ * Invalidates the cached detail and list responses so the deleted account
66
+ * is not served from cache.
58
67
  */
59
68
  deleteManagedAccount(accountId: string): Promise<void>;
60
69
  /**
61
70
  * Add a manager to a managed account.
62
71
  * Requires owner or admin role on the account.
63
72
  *
73
+ * Mutates the account's `managers[]`, which is returned by the detail and
74
+ * list reads — invalidate both so they re-fetch the updated manager set.
75
+ *
64
76
  * @param accountId - The managed account to add the manager to
65
77
  * @param userId - The user to grant management access
66
78
  * @param role - The role to assign: 'admin' or 'editor'
@@ -70,6 +82,9 @@ export declare function OxyServicesManagedAccountsMixin<T extends typeof OxyServ
70
82
  * Remove a manager from a managed account.
71
83
  * Requires owner role.
72
84
  *
85
+ * Invalidates the detail and list responses so the updated `managers[]`
86
+ * is observed on the next read (see `addManager`).
87
+ *
73
88
  * @param accountId - The managed account
74
89
  * @param userId - The manager to remove
75
90
  */
@@ -27,7 +27,13 @@ export declare function OxyServicesPrivacyMixin<T extends typeof OxyServicesBase
27
27
  */
28
28
  getBlockedUsers(): Promise<BlockedUser[]>;
29
29
  /**
30
- * Block a user
30
+ * Block a user.
31
+ *
32
+ * Invalidates the cached `GET /privacy/blocked` response after the write.
33
+ * `getBlockedUsers` caches for ~1 minute (identity-scoped); without busting
34
+ * that entry, a consumer that re-reads the blocked list within the TTL
35
+ * window would not see the user it just blocked. `clearCacheEntry` deletes
36
+ * every identity-scoped variant of the key.
31
37
  * @param userId - The user ID to block
32
38
  * @returns Success message
33
39
  */
@@ -35,7 +41,10 @@ export declare function OxyServicesPrivacyMixin<T extends typeof OxyServicesBase
35
41
  message: string;
36
42
  }>;
37
43
  /**
38
- * Unblock a user
44
+ * Unblock a user.
45
+ *
46
+ * Busts the cached `GET /privacy/blocked` response so a remount reads the
47
+ * fresh list without the just-unblocked user (see `blockUser`).
39
48
  * @param userId - The user ID to unblock
40
49
  * @returns Success message
41
50
  */
@@ -54,7 +63,13 @@ export declare function OxyServicesPrivacyMixin<T extends typeof OxyServicesBase
54
63
  */
55
64
  getRestrictedUsers(): Promise<RestrictedUser[]>;
56
65
  /**
57
- * Restrict a user (limit their interactions without fully blocking)
66
+ * Restrict a user (limit their interactions without fully blocking).
67
+ *
68
+ * Invalidates the cached `GET /privacy/restricted` response after the write.
69
+ * `getRestrictedUsers` caches for ~1 minute (identity-scoped); without
70
+ * busting that entry, a consumer that re-reads the restricted list within
71
+ * the TTL window would not see the user it just restricted.
72
+ * `clearCacheEntry` deletes every identity-scoped variant of the key.
58
73
  * @param userId - The user ID to restrict
59
74
  * @returns Success message
60
75
  */
@@ -62,7 +77,10 @@ export declare function OxyServicesPrivacyMixin<T extends typeof OxyServicesBase
62
77
  message: string;
63
78
  }>;
64
79
  /**
65
- * Unrestrict a user
80
+ * Unrestrict a user.
81
+ *
82
+ * Busts the cached `GET /privacy/restricted` response so a remount reads the
83
+ * fresh list without the just-unrestricted user (see `restrictUser`).
66
84
  * @param userId - The user ID to unrestrict
67
85
  * @returns Success message
68
86
  */
@@ -182,7 +182,14 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
182
182
  */
183
183
  getPrivacySettings(userId?: string): Promise<PrivacySettings>;
184
184
  /**
185
- * Update privacy settings
185
+ * Update privacy settings.
186
+ *
187
+ * Invalidates the cached `GET /privacy/<id>/privacy` response (the exact
188
+ * key `getPrivacySettings` reads, scoped to the same `id`) after the write.
189
+ * `getPrivacySettings` caches for ~2 minutes (identity-scoped); without
190
+ * busting that entry, a follow-up read within the TTL window returns the
191
+ * pre-update settings. `clearCacheEntry` deletes every identity-scoped
192
+ * variant of the key.
186
193
  * @param settings - Partial privacy settings object
187
194
  * @param userId - The user ID (defaults to current user)
188
195
  */
@@ -151,6 +151,18 @@ export declare function OxyServicesWorkspacesMixin<T extends typeof OxyServicesB
151
151
  * @param data - Target user id.
152
152
  */
153
153
  transferWorkspaceOwnership(workspaceId: string, data: TransferWorkspaceOwnershipInput): Promise<WorkspaceSuccessResult>;
154
+ /**
155
+ * Bust the cached member list and detail for a workspace after a membership
156
+ * mutation. The member list (`getWorkspaceMembers`) and the detail
157
+ * (`getWorkspace`, which can embed member counts) both go stale when the
158
+ * member set or a member's role changes.
159
+ *
160
+ * Internal helper (leading underscore); not part of the supported public
161
+ * surface. Public rather than `private` because mixins compose into an
162
+ * exported anonymous class, where TypeScript cannot represent a private
163
+ * member in the emitted declaration file (TS4094).
164
+ */
165
+ _invalidateWorkspaceMembership(workspaceId: string): void;
154
166
  httpService: import("../HttpService").HttpService;
155
167
  cloudURL: string;
156
168
  config: import("../OxyServices.base").OxyConfig;
@@ -27,7 +27,19 @@ export interface OxyConfig {
27
27
  * bounce (which uses the RP origin, not this registered client id).
28
28
  */
29
29
  clientId?: string;
30
+ /**
31
+ * Enable the per-instance GET response cache. Defaults to `true` (5-minute
32
+ * TTL). Set to `false` to disable caching entirely for this instance — GET
33
+ * responses are then never stored and never served from cache, so every read
34
+ * hits the network. Useful for a linked backend client where another layer
35
+ * (e.g. React Query) is the single cache authority and the SDK's own cache
36
+ * would otherwise serve stale data after a write.
37
+ */
30
38
  enableCache?: boolean;
39
+ /**
40
+ * Cache TTL in milliseconds (default: 5 minutes). A value `<= 0` disables the
41
+ * per-instance GET response cache, equivalent to `enableCache: false`.
42
+ */
31
43
  cacheTTL?: number;
32
44
  enableRequestDeduplication?: boolean;
33
45
  enableRetry?: boolean;
@@ -59,7 +59,10 @@ export declare class TTLCache<T> {
59
59
  * Set a value in cache
60
60
  * @param key Cache key
61
61
  * @param data Data to cache
62
- * @param ttl Optional TTL override (uses default if not provided)
62
+ * @param ttl Optional TTL override (uses default if not provided). An
63
+ * explicit `0` or negative value is honored as "already expired" — the
64
+ * entry is stored with `expiresAt <= now`, so the next `get`/`has` treats
65
+ * it as a miss — rather than silently falling back to the default TTL.
63
66
  */
64
67
  set(key: string, data: T, ttl?: number): void;
65
68
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "3.8.1",
3
+ "version": "3.9.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -125,9 +125,6 @@
125
125
  },
126
126
  "express": {
127
127
  "optional": true
128
- },
129
- "express-rate-limit": {
130
- "optional": true
131
128
  }
132
129
  },
133
130
  "devDependencies": {
@@ -202,6 +202,15 @@ export class HttpService {
202
202
  private baseURL: string;
203
203
  private tokenStore: TokenStore;
204
204
  private cache: TTLCache<any>;
205
+ /**
206
+ * When true, the per-instance GET response cache is OFF: GET responses are
207
+ * never read from nor written to {@link cache}, so every request hits the
208
+ * network. Set from `config.enableCache === false` OR `config.cacheTTL <= 0`.
209
+ * A disabled instance does not register its cache for the global cleanup
210
+ * interval (nothing ever lands in it). Request deduplication is unaffected —
211
+ * concurrent identical in-flight requests still collapse into one.
212
+ */
213
+ private readonly cacheDisabled: boolean;
205
214
  private deduplicator: RequestDeduplicator;
206
215
  private requestQueue: RequestQueue;
207
216
  private logger: SimpleLogger;
@@ -252,9 +261,19 @@ export class HttpService {
252
261
  'HttpService'
253
262
  );
254
263
 
255
- // Initialize performance infrastructure
256
- this.cache = new TTLCache<any>(config.cacheTTL || 5 * 60 * 1000);
257
- registerCacheForCleanup(this.cache);
264
+ // Initialize performance infrastructure. The per-instance GET response
265
+ // cache is disabled when the consumer explicitly opts out
266
+ // (`enableCache: false`) or asks for a non-positive TTL (`cacheTTL <= 0`).
267
+ // When disabled, nothing is ever stored, so there is no reason to register
268
+ // the cache for the global cleanup interval. Default (config unset) keeps
269
+ // caching ON with the 5-minute TTL — unchanged for existing consumers.
270
+ this.cacheDisabled =
271
+ config.enableCache === false ||
272
+ (typeof config.cacheTTL === 'number' && config.cacheTTL <= 0);
273
+ this.cache = new TTLCache<any>(config.cacheTTL && config.cacheTTL > 0 ? config.cacheTTL : 5 * 60 * 1000);
274
+ if (!this.cacheDisabled) {
275
+ registerCacheForCleanup(this.cache);
276
+ }
258
277
  this.deduplicator = new RequestDeduplicator();
259
278
  this.requestQueue = new RequestQueue(
260
279
  config.maxConcurrentRequests || 10,
@@ -352,13 +371,18 @@ export class HttpService {
352
371
  params,
353
372
  timeout = this.config.requestTimeout || DEFAULT_REQUEST_TIMEOUT_MS,
354
373
  signal,
355
- cache = method === 'GET',
374
+ cache: cacheRequested = method === 'GET',
356
375
  cacheTTL,
357
376
  deduplicate = true,
358
377
  retry = this.config.enableRetry !== false,
359
378
  maxRetries = this.config.maxRetries || 3,
360
379
  } = config;
361
380
 
381
+ // A per-instance disabled cache (`enableCache:false` / `cacheTTL<=0`)
382
+ // overrides any per-request `cache:true`: nothing is read from nor written
383
+ // to the response cache. Request deduplication below is unaffected.
384
+ const cache = cacheRequested && !this.cacheDisabled;
385
+
362
386
  // Generate cache key (optimized for large objects)
363
387
  const cacheKey = cache ? this.generateCacheKey(method, url, data || params) : null;
364
388
 
@@ -129,9 +129,23 @@ export class OxyServicesBase {
129
129
  * OxyServices instance mounted in OxyProvider. The returned client has its own
130
130
  * base URL, cache and request queue, but its bearer token is kept in lockstep
131
131
  * with this session and its 401 refresh path delegates back to this session.
132
+ *
133
+ * **GET response caching is OFF by default for linked clients.** The SDK's
134
+ * per-instance GET cache is only safe where the SDK OWNS invalidation: on the
135
+ * canonical OxyServices client, every mutation (`updateProfile`, `followUser`,
136
+ * `blockUser`, …) busts the matching cached GET. A linked client targets the
137
+ * consuming app's OWN backend (`api.mention.earth`, `api.syra.fm`, …), whose
138
+ * resources and write endpoints the SDK has no knowledge of — so it cannot
139
+ * invalidate them, and a cached GET there would silently serve stale data
140
+ * after the app mutates its own data. Caching is therefore unsafe-by-construction
141
+ * here and is left to the consumer's own layer (React Query / stores), which
142
+ * owns its invalidation. Pass `createLinkedClient({ baseURL, enableCache: true })`
143
+ * to explicitly opt back in when the consumer accepts that responsibility.
132
144
  */
133
145
  public createLinkedClient(config: OxyConfig): LinkedHttpClient {
134
- const client = new HttpService(config);
146
+ // Default the GET cache OFF unless the caller explicitly opts in (see the
147
+ // method doc): the SDK cannot invalidate the consumer backend's resources.
148
+ const client = new HttpService({ ...config, enableCache: config.enableCache ?? false });
135
149
 
136
150
  const syncToken = (accessToken: string | null): void => {
137
151
  const currentAccessToken = client.getAccessToken();
@@ -266,4 +266,72 @@ describe('HttpService identity-scoped response cache', () => {
266
266
  expect(cacheWarnings.length).toBe(0);
267
267
  });
268
268
  });
269
+
270
+ /**
271
+ * `enableCache: false` (or `cacheTTL <= 0`) must fully disable the
272
+ * per-instance GET response cache: every read hits the network, even when the
273
+ * per-request `cache: true` flag is set. This is the lever Mention's linked
274
+ * backend client uses to make React Query the single cache authority and
275
+ * avoid stale-after-write reads from the SDK's own cache. Default behavior
276
+ * (config unset) is unchanged — caching stays ON.
277
+ */
278
+ describe('per-instance cache disable', () => {
279
+ it('never serves a cached GET when enableCache is false', async () => {
280
+ const http = new HttpService({
281
+ baseURL: 'http://test.invalid',
282
+ enableRetry: false,
283
+ requestTimeout: 1000,
284
+ enableCache: false,
285
+ });
286
+ http.setTokens(makeJwt({ userId: 'user-1' }));
287
+
288
+ fetchMock.mockResolvedValueOnce(jsonResponse({ v: 1 }));
289
+ const first = await http.get<{ v: number }>('/some/resource', { cache: true });
290
+ expect(first.v).toBe(1);
291
+ expect(fetchMock).toHaveBeenCalledTimes(1);
292
+
293
+ // A second identical read with cache:true must STILL hit the network,
294
+ // because the instance cache is disabled. Nothing is ever stored.
295
+ fetchMock.mockResolvedValueOnce(jsonResponse({ v: 2 }));
296
+ const second = await http.get<{ v: number }>('/some/resource', { cache: true });
297
+ expect(second.v).toBe(2);
298
+ expect(fetchMock).toHaveBeenCalledTimes(2);
299
+ expect(http.getCacheStats().size).toBe(0);
300
+ });
301
+
302
+ it('treats cacheTTL <= 0 as a disabled cache', async () => {
303
+ const http = new HttpService({
304
+ baseURL: 'http://test.invalid',
305
+ enableRetry: false,
306
+ requestTimeout: 1000,
307
+ cacheTTL: 0,
308
+ });
309
+ http.setTokens(makeJwt({ userId: 'user-1' }));
310
+
311
+ fetchMock.mockResolvedValueOnce(jsonResponse({ v: 1 }));
312
+ await http.get('/some/resource', { cache: true });
313
+ fetchMock.mockResolvedValueOnce(jsonResponse({ v: 2 }));
314
+ await http.get('/some/resource', { cache: true });
315
+
316
+ expect(fetchMock).toHaveBeenCalledTimes(2);
317
+ expect(http.getCacheStats().size).toBe(0);
318
+ });
319
+
320
+ it('still caches by default (config unset) — no behavior change', async () => {
321
+ const http = new HttpService({
322
+ baseURL: 'http://test.invalid',
323
+ enableRetry: false,
324
+ requestTimeout: 1000,
325
+ });
326
+ http.setTokens(makeJwt({ userId: 'user-1' }));
327
+
328
+ fetchMock.mockResolvedValueOnce(jsonResponse({ v: 1 }));
329
+ const first = await http.get<{ v: number }>('/some/resource', { cache: true });
330
+ // Second read is a warm cache hit — no second network call.
331
+ const second = await http.get<{ v: number }>('/some/resource', { cache: true });
332
+
333
+ expect(first).toEqual(second);
334
+ expect(fetchMock).toHaveBeenCalledTimes(1);
335
+ });
336
+ });
269
337
  });
@@ -206,4 +206,65 @@ describe('OxyServices.createLinkedClient', () => {
206
206
 
207
207
  linked.dispose();
208
208
  });
209
+
210
+ /**
211
+ * GET response caching is OFF by default for linked clients: the SDK cannot
212
+ * invalidate the consumer backend's resources, so a cached GET there would
213
+ * serve stale data after the app mutates its own data. The consumer's own
214
+ * layer (React Query / stores) owns caching. An explicit `enableCache: true`
215
+ * opts back in.
216
+ */
217
+ describe('linked client GET cache default', () => {
218
+ it('does NOT cache GETs by default — every read hits the network', async () => {
219
+ const fetchMock = jest.fn();
220
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
221
+
222
+ const oxy = createServices();
223
+ const accessToken = createJwt({
224
+ userId: 'user_1',
225
+ exp: Math.floor(Date.now() / 1000) + 3600,
226
+ });
227
+ oxy.setTokens(accessToken);
228
+ const linked = oxy.createLinkedClient({ baseURL: 'https://api.mention.earth' });
229
+
230
+ // Two identical GETs with cache:true — both MUST hit the network because
231
+ // the linked client's cache is disabled by default.
232
+ fetchMock.mockResolvedValueOnce(jsonResponse({ v: 1 }));
233
+ const first = await linked.client.get<{ v: number }>('/feed/mtn', { cache: true });
234
+ fetchMock.mockResolvedValueOnce(jsonResponse({ v: 2 }));
235
+ const second = await linked.client.get<{ v: number }>('/feed/mtn', { cache: true });
236
+
237
+ expect(first.v).toBe(1);
238
+ expect(second.v).toBe(2);
239
+ expect(fetchMock).toHaveBeenCalledTimes(2);
240
+
241
+ linked.dispose();
242
+ });
243
+
244
+ it('caches GETs when the caller explicitly opts in with enableCache: true', async () => {
245
+ const fetchMock = jest.fn();
246
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
247
+
248
+ const oxy = createServices();
249
+ const accessToken = createJwt({
250
+ userId: 'user_1',
251
+ exp: Math.floor(Date.now() / 1000) + 3600,
252
+ });
253
+ oxy.setTokens(accessToken);
254
+ const linked = oxy.createLinkedClient({
255
+ baseURL: 'https://api.mention.earth',
256
+ enableCache: true,
257
+ });
258
+
259
+ // Second identical GET is a warm cache hit — only one network call.
260
+ fetchMock.mockResolvedValueOnce(jsonResponse({ v: 1 }));
261
+ const first = await linked.client.get<{ v: number }>('/feed/mtn', { cache: true });
262
+ const second = await linked.client.get<{ v: number }>('/feed/mtn', { cache: true });
263
+
264
+ expect(first).toEqual(second);
265
+ expect(fetchMock).toHaveBeenCalledTimes(1);
266
+
267
+ linked.dispose();
268
+ });
269
+ });
209
270
  });