@oxyhq/core 3.8.1 → 3.8.2
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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +18 -4
- package/dist/cjs/mixins/OxyServices.applications.js +69 -6
- package/dist/cjs/mixins/OxyServices.assets.js +16 -3
- package/dist/cjs/mixins/OxyServices.features.js +47 -10
- package/dist/cjs/mixins/OxyServices.managedAccounts.js +29 -3
- package/dist/cjs/mixins/OxyServices.privacy.js +34 -8
- package/dist/cjs/mixins/OxyServices.topics.js +5 -1
- package/dist/cjs/mixins/OxyServices.user.js +11 -2
- package/dist/cjs/mixins/OxyServices.workspaces.js +38 -3
- package/dist/cjs/utils/cache.js +9 -2
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +18 -4
- package/dist/esm/mixins/OxyServices.applications.js +69 -6
- package/dist/esm/mixins/OxyServices.assets.js +16 -3
- package/dist/esm/mixins/OxyServices.features.js +47 -10
- package/dist/esm/mixins/OxyServices.managedAccounts.js +29 -3
- package/dist/esm/mixins/OxyServices.privacy.js +34 -8
- package/dist/esm/mixins/OxyServices.topics.js +5 -1
- package/dist/esm/mixins/OxyServices.user.js +11 -2
- package/dist/esm/mixins/OxyServices.workspaces.js +38 -3
- package/dist/esm/utils/cache.js +9 -2
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +9 -0
- package/dist/types/mixins/OxyServices.applications.d.ts +26 -0
- package/dist/types/mixins/OxyServices.features.d.ts +27 -6
- package/dist/types/mixins/OxyServices.managedAccounts.d.ts +16 -1
- package/dist/types/mixins/OxyServices.privacy.d.ts +22 -4
- package/dist/types/mixins/OxyServices.user.d.ts +8 -1
- package/dist/types/mixins/OxyServices.workspaces.d.ts +12 -0
- package/dist/types/models/interfaces.d.ts +12 -0
- package/dist/types/utils/cache.d.ts +4 -1
- package/package.json +1 -4
- package/src/HttpService.ts +28 -4
- package/src/__tests__/httpServiceCache.test.ts +68 -0
- package/src/mixins/OxyServices.applications.ts +71 -6
- package/src/mixins/OxyServices.assets.ts +16 -3
- package/src/mixins/OxyServices.features.ts +47 -10
- package/src/mixins/OxyServices.managedAccounts.ts +29 -3
- package/src/mixins/OxyServices.privacy.ts +34 -8
- package/src/mixins/OxyServices.topics.ts +5 -1
- package/src/mixins/OxyServices.user.ts +11 -2
- package/src/mixins/OxyServices.workspaces.ts +39 -3
- package/src/mixins/__tests__/privacyCacheInvalidation.test.ts +147 -0
- package/src/models/interfaces.ts +13 -1
- package/src/utils/cache.ts +9 -2
|
@@ -159,10 +159,14 @@ export function OxyServicesFeaturesMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
159
159
|
*/
|
|
160
160
|
async subscribe(planId: string, paymentMethodId?: string): Promise<SubscriptionResult> {
|
|
161
161
|
return this.withAuthRetry(async () => {
|
|
162
|
-
|
|
162
|
+
const result = await this.makeRequest<SubscriptionResult>('POST', '/subscriptions/subscribe', {
|
|
163
163
|
planId,
|
|
164
164
|
paymentMethodId,
|
|
165
165
|
}, { cache: false });
|
|
166
|
+
// The current subscription changed — bust its cached read so
|
|
167
|
+
// `getCurrentSubscription()` reflects the new plan immediately.
|
|
168
|
+
this.clearCacheEntry('GET:/subscriptions/current');
|
|
169
|
+
return result;
|
|
166
170
|
}, 'subscribe');
|
|
167
171
|
}
|
|
168
172
|
|
|
@@ -171,10 +175,12 @@ export function OxyServicesFeaturesMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
171
175
|
*/
|
|
172
176
|
async subscribeToFeature(featureId: string, paymentMethodId?: string): Promise<SubscriptionResult> {
|
|
173
177
|
return this.withAuthRetry(async () => {
|
|
174
|
-
|
|
178
|
+
const result = await this.makeRequest<SubscriptionResult>('POST', '/subscriptions/features/subscribe', {
|
|
175
179
|
featureId,
|
|
176
180
|
paymentMethodId,
|
|
177
181
|
}, { cache: false });
|
|
182
|
+
this.clearCacheEntry('GET:/subscriptions/current');
|
|
183
|
+
return result;
|
|
178
184
|
}, 'subscribeToFeature');
|
|
179
185
|
}
|
|
180
186
|
|
|
@@ -186,6 +192,7 @@ export function OxyServicesFeaturesMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
186
192
|
await this.makeRequest('POST', `/subscriptions/${subscriptionId}/cancel`, undefined, {
|
|
187
193
|
cache: false,
|
|
188
194
|
});
|
|
195
|
+
this.clearCacheEntry('GET:/subscriptions/current');
|
|
189
196
|
}, 'cancelSubscription');
|
|
190
197
|
}
|
|
191
198
|
|
|
@@ -197,6 +204,7 @@ export function OxyServicesFeaturesMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
197
204
|
await this.makeRequest('POST', `/subscriptions/${subscriptionId}/reactivate`, undefined, {
|
|
198
205
|
cache: false,
|
|
199
206
|
});
|
|
207
|
+
this.clearCacheEntry('GET:/subscriptions/current');
|
|
200
208
|
}, 'reactivateSubscription');
|
|
201
209
|
}
|
|
202
210
|
|
|
@@ -248,45 +256,65 @@ export function OxyServicesFeaturesMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
248
256
|
}
|
|
249
257
|
|
|
250
258
|
/**
|
|
251
|
-
* Save an item
|
|
259
|
+
* Save an item.
|
|
260
|
+
*
|
|
261
|
+
* Busts the cached own saved-items list (`GET /saves`, ~short TTL) so a
|
|
262
|
+
* follow-up `getSavedItems()` observes the new item. The `userId`-scoped
|
|
263
|
+
* variant (`/users/<id>/saves`) is another user's list and is not
|
|
264
|
+
* affected by the caller's own save.
|
|
252
265
|
*/
|
|
253
266
|
async saveItem(itemId: string, itemType: string, collectionId?: string): Promise<SavedItem> {
|
|
254
267
|
return this.withAuthRetry(async () => {
|
|
255
|
-
|
|
268
|
+
const result = await this.makeRequest<SavedItem>('POST', '/saves', {
|
|
256
269
|
itemId,
|
|
257
270
|
itemType,
|
|
258
271
|
collectionId,
|
|
259
272
|
}, { cache: false });
|
|
273
|
+
this.clearCacheEntry('GET:/saves');
|
|
274
|
+
return result;
|
|
260
275
|
}, 'saveItem');
|
|
261
276
|
}
|
|
262
277
|
|
|
263
278
|
/**
|
|
264
|
-
* Remove an item from saves
|
|
279
|
+
* Remove an item from saves.
|
|
280
|
+
*
|
|
281
|
+
* Busts the cached own saved-items list so the removed item is gone on
|
|
282
|
+
* the next read (see `saveItem`).
|
|
265
283
|
*/
|
|
266
284
|
async removeSavedItem(saveId: string): Promise<void> {
|
|
267
285
|
return this.withAuthRetry(async () => {
|
|
268
286
|
await this.makeRequest('DELETE', `/saves/${saveId}`, undefined, { cache: false });
|
|
287
|
+
this.clearCacheEntry('GET:/saves');
|
|
269
288
|
}, 'removeSavedItem');
|
|
270
289
|
}
|
|
271
290
|
|
|
272
291
|
/**
|
|
273
|
-
* Create a collection
|
|
292
|
+
* Create a collection.
|
|
293
|
+
*
|
|
294
|
+
* Busts the cached own collections list (`GET /collections`) so the new
|
|
295
|
+
* collection appears on the next read.
|
|
274
296
|
*/
|
|
275
297
|
async createCollection(name: string, description?: string): Promise<Collection> {
|
|
276
298
|
return this.withAuthRetry(async () => {
|
|
277
|
-
|
|
299
|
+
const result = await this.makeRequest<Collection>('POST', '/collections', {
|
|
278
300
|
name,
|
|
279
301
|
description,
|
|
280
302
|
}, { cache: false });
|
|
303
|
+
this.clearCacheEntry('GET:/collections');
|
|
304
|
+
return result;
|
|
281
305
|
}, 'createCollection');
|
|
282
306
|
}
|
|
283
307
|
|
|
284
308
|
/**
|
|
285
|
-
* Delete a collection
|
|
309
|
+
* Delete a collection.
|
|
310
|
+
*
|
|
311
|
+
* Busts the cached own collections list so the deleted collection is
|
|
312
|
+
* gone on the next read (see `createCollection`).
|
|
286
313
|
*/
|
|
287
314
|
async deleteCollection(collectionId: string): Promise<void> {
|
|
288
315
|
return this.withAuthRetry(async () => {
|
|
289
316
|
await this.makeRequest('DELETE', `/collections/${collectionId}`, undefined, { cache: false });
|
|
317
|
+
this.clearCacheEntry('GET:/collections');
|
|
290
318
|
}, 'deleteCollection');
|
|
291
319
|
}
|
|
292
320
|
|
|
@@ -330,20 +358,29 @@ export function OxyServicesFeaturesMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
330
358
|
}
|
|
331
359
|
|
|
332
360
|
/**
|
|
333
|
-
* Clear user history
|
|
361
|
+
* Clear user history.
|
|
362
|
+
*
|
|
363
|
+
* `getUserHistory` caches per (limit, offset) page, so its cache key
|
|
364
|
+
* carries serialized params (`GET:/history` and `GET:/history:<params>`).
|
|
365
|
+
* A prefix sweep busts every cached page of the own history at once.
|
|
334
366
|
*/
|
|
335
367
|
async clearUserHistory(): Promise<void> {
|
|
336
368
|
return this.withAuthRetry(async () => {
|
|
337
369
|
await this.makeRequest('DELETE', '/history', undefined, { cache: false });
|
|
370
|
+
this.clearCacheByPrefix('GET:/history');
|
|
338
371
|
}, 'clearUserHistory');
|
|
339
372
|
}
|
|
340
373
|
|
|
341
374
|
/**
|
|
342
|
-
* Delete a history item
|
|
375
|
+
* Delete a history item.
|
|
376
|
+
*
|
|
377
|
+
* Busts every cached page of the own history (see `clearUserHistory`)
|
|
378
|
+
* so the removed item no longer appears on the next read.
|
|
343
379
|
*/
|
|
344
380
|
async deleteHistoryItem(itemId: string): Promise<void> {
|
|
345
381
|
return this.withAuthRetry(async () => {
|
|
346
382
|
await this.makeRequest('DELETE', `/history/${itemId}`, undefined, { cache: false });
|
|
383
|
+
this.clearCacheByPrefix('GET:/history');
|
|
347
384
|
}, 'deleteHistoryItem');
|
|
348
385
|
}
|
|
349
386
|
|
|
@@ -41,13 +41,17 @@ export function OxyServicesManagedAccountsMixin<T extends typeof OxyServicesBase
|
|
|
41
41
|
* Create a new managed account (sub-account).
|
|
42
42
|
*
|
|
43
43
|
* The server creates a User document with `isManagedAccount: true` and links
|
|
44
|
-
* it to the authenticated user as owner.
|
|
44
|
+
* it to the authenticated user as owner. Invalidates the cached
|
|
45
|
+
* `GET /managed-accounts` list (~2-minute TTL, identity-scoped) so the next
|
|
46
|
+
* read includes the newly created account.
|
|
45
47
|
*/
|
|
46
48
|
async createManagedAccount(data: CreateManagedAccountInput): Promise<ManagedAccount> {
|
|
47
49
|
try {
|
|
48
|
-
|
|
50
|
+
const result = await this.makeRequest<ManagedAccount>('POST', '/managed-accounts', data, {
|
|
49
51
|
cache: false,
|
|
50
52
|
});
|
|
53
|
+
this.clearCacheEntry('GET:/managed-accounts');
|
|
54
|
+
return result;
|
|
51
55
|
} catch (error) {
|
|
52
56
|
throw this.handleError(error);
|
|
53
57
|
}
|
|
@@ -84,12 +88,19 @@ export function OxyServicesManagedAccountsMixin<T extends typeof OxyServicesBase
|
|
|
84
88
|
/**
|
|
85
89
|
* Update a managed account's profile data.
|
|
86
90
|
* Requires owner or admin role.
|
|
91
|
+
*
|
|
92
|
+
* Invalidates both the cached detail (`GET /managed-accounts/<id>`) and the
|
|
93
|
+
* cached list (`GET /managed-accounts`, which embeds account profile data)
|
|
94
|
+
* so neither serves the pre-update snapshot within their ~2-minute TTL.
|
|
87
95
|
*/
|
|
88
96
|
async updateManagedAccount(accountId: string, data: Partial<CreateManagedAccountInput>): Promise<ManagedAccount> {
|
|
89
97
|
try {
|
|
90
|
-
|
|
98
|
+
const result = await this.makeRequest<ManagedAccount>('PUT', `/managed-accounts/${accountId}`, data, {
|
|
91
99
|
cache: false,
|
|
92
100
|
});
|
|
101
|
+
this.clearCacheEntry(`GET:/managed-accounts/${accountId}`);
|
|
102
|
+
this.clearCacheEntry('GET:/managed-accounts');
|
|
103
|
+
return result;
|
|
93
104
|
} catch (error) {
|
|
94
105
|
throw this.handleError(error);
|
|
95
106
|
}
|
|
@@ -98,12 +109,17 @@ export function OxyServicesManagedAccountsMixin<T extends typeof OxyServicesBase
|
|
|
98
109
|
/**
|
|
99
110
|
* Delete a managed account permanently.
|
|
100
111
|
* Requires owner role.
|
|
112
|
+
*
|
|
113
|
+
* Invalidates the cached detail and list responses so the deleted account
|
|
114
|
+
* is not served from cache.
|
|
101
115
|
*/
|
|
102
116
|
async deleteManagedAccount(accountId: string): Promise<void> {
|
|
103
117
|
try {
|
|
104
118
|
await this.makeRequest<void>('DELETE', `/managed-accounts/${accountId}`, undefined, {
|
|
105
119
|
cache: false,
|
|
106
120
|
});
|
|
121
|
+
this.clearCacheEntry(`GET:/managed-accounts/${accountId}`);
|
|
122
|
+
this.clearCacheEntry('GET:/managed-accounts');
|
|
107
123
|
} catch (error) {
|
|
108
124
|
throw this.handleError(error);
|
|
109
125
|
}
|
|
@@ -113,6 +129,9 @@ export function OxyServicesManagedAccountsMixin<T extends typeof OxyServicesBase
|
|
|
113
129
|
* Add a manager to a managed account.
|
|
114
130
|
* Requires owner or admin role on the account.
|
|
115
131
|
*
|
|
132
|
+
* Mutates the account's `managers[]`, which is returned by the detail and
|
|
133
|
+
* list reads — invalidate both so they re-fetch the updated manager set.
|
|
134
|
+
*
|
|
116
135
|
* @param accountId - The managed account to add the manager to
|
|
117
136
|
* @param userId - The user to grant management access
|
|
118
137
|
* @param role - The role to assign: 'admin' or 'editor'
|
|
@@ -122,6 +141,8 @@ export function OxyServicesManagedAccountsMixin<T extends typeof OxyServicesBase
|
|
|
122
141
|
await this.makeRequest<void>('POST', `/managed-accounts/${accountId}/managers`, { userId, role }, {
|
|
123
142
|
cache: false,
|
|
124
143
|
});
|
|
144
|
+
this.clearCacheEntry(`GET:/managed-accounts/${accountId}`);
|
|
145
|
+
this.clearCacheEntry('GET:/managed-accounts');
|
|
125
146
|
} catch (error) {
|
|
126
147
|
throw this.handleError(error);
|
|
127
148
|
}
|
|
@@ -131,6 +152,9 @@ export function OxyServicesManagedAccountsMixin<T extends typeof OxyServicesBase
|
|
|
131
152
|
* Remove a manager from a managed account.
|
|
132
153
|
* Requires owner role.
|
|
133
154
|
*
|
|
155
|
+
* Invalidates the detail and list responses so the updated `managers[]`
|
|
156
|
+
* is observed on the next read (see `addManager`).
|
|
157
|
+
*
|
|
134
158
|
* @param accountId - The managed account
|
|
135
159
|
* @param userId - The manager to remove
|
|
136
160
|
*/
|
|
@@ -139,6 +163,8 @@ export function OxyServicesManagedAccountsMixin<T extends typeof OxyServicesBase
|
|
|
139
163
|
await this.makeRequest<void>('DELETE', `/managed-accounts/${accountId}/managers/${userId}`, undefined, {
|
|
140
164
|
cache: false,
|
|
141
165
|
});
|
|
166
|
+
this.clearCacheEntry(`GET:/managed-accounts/${accountId}`);
|
|
167
|
+
this.clearCacheEntry('GET:/managed-accounts');
|
|
142
168
|
} catch (error) {
|
|
143
169
|
throw this.handleError(error);
|
|
144
170
|
}
|
|
@@ -63,7 +63,13 @@ export function OxyServicesPrivacyMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
|
-
* Block a user
|
|
66
|
+
* Block a user.
|
|
67
|
+
*
|
|
68
|
+
* Invalidates the cached `GET /privacy/blocked` response after the write.
|
|
69
|
+
* `getBlockedUsers` caches for ~1 minute (identity-scoped); without busting
|
|
70
|
+
* that entry, a consumer that re-reads the blocked list within the TTL
|
|
71
|
+
* window would not see the user it just blocked. `clearCacheEntry` deletes
|
|
72
|
+
* every identity-scoped variant of the key.
|
|
67
73
|
* @param userId - The user ID to block
|
|
68
74
|
* @returns Success message
|
|
69
75
|
*/
|
|
@@ -72,16 +78,21 @@ export function OxyServicesPrivacyMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
72
78
|
if (!userId) {
|
|
73
79
|
throw new Error('User ID is required');
|
|
74
80
|
}
|
|
75
|
-
|
|
81
|
+
const result = await this.makeRequest<{ message: string }>('POST', `/privacy/blocked/${userId}`, undefined, {
|
|
76
82
|
cache: false,
|
|
77
83
|
});
|
|
84
|
+
this.clearCacheEntry('GET:/privacy/blocked');
|
|
85
|
+
return result;
|
|
78
86
|
} catch (error) {
|
|
79
87
|
throw this.handleError(error);
|
|
80
88
|
}
|
|
81
89
|
}
|
|
82
90
|
|
|
83
91
|
/**
|
|
84
|
-
* Unblock a user
|
|
92
|
+
* Unblock a user.
|
|
93
|
+
*
|
|
94
|
+
* Busts the cached `GET /privacy/blocked` response so a remount reads the
|
|
95
|
+
* fresh list without the just-unblocked user (see `blockUser`).
|
|
85
96
|
* @param userId - The user ID to unblock
|
|
86
97
|
* @returns Success message
|
|
87
98
|
*/
|
|
@@ -90,9 +101,11 @@ export function OxyServicesPrivacyMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
90
101
|
if (!userId) {
|
|
91
102
|
throw new Error('User ID is required');
|
|
92
103
|
}
|
|
93
|
-
|
|
104
|
+
const result = await this.makeRequest<{ message: string }>('DELETE', `/privacy/blocked/${userId}`, undefined, {
|
|
94
105
|
cache: false,
|
|
95
106
|
});
|
|
107
|
+
this.clearCacheEntry('GET:/privacy/blocked');
|
|
108
|
+
return result;
|
|
96
109
|
} catch (error) {
|
|
97
110
|
throw this.handleError(error);
|
|
98
111
|
}
|
|
@@ -131,7 +144,13 @@ export function OxyServicesPrivacyMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
131
144
|
}
|
|
132
145
|
|
|
133
146
|
/**
|
|
134
|
-
* Restrict a user (limit their interactions without fully blocking)
|
|
147
|
+
* Restrict a user (limit their interactions without fully blocking).
|
|
148
|
+
*
|
|
149
|
+
* Invalidates the cached `GET /privacy/restricted` response after the write.
|
|
150
|
+
* `getRestrictedUsers` caches for ~1 minute (identity-scoped); without
|
|
151
|
+
* busting that entry, a consumer that re-reads the restricted list within
|
|
152
|
+
* the TTL window would not see the user it just restricted.
|
|
153
|
+
* `clearCacheEntry` deletes every identity-scoped variant of the key.
|
|
135
154
|
* @param userId - The user ID to restrict
|
|
136
155
|
* @returns Success message
|
|
137
156
|
*/
|
|
@@ -140,16 +159,21 @@ export function OxyServicesPrivacyMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
140
159
|
if (!userId) {
|
|
141
160
|
throw new Error('User ID is required');
|
|
142
161
|
}
|
|
143
|
-
|
|
162
|
+
const result = await this.makeRequest<{ message: string }>('POST', `/privacy/restricted/${userId}`, undefined, {
|
|
144
163
|
cache: false,
|
|
145
164
|
});
|
|
165
|
+
this.clearCacheEntry('GET:/privacy/restricted');
|
|
166
|
+
return result;
|
|
146
167
|
} catch (error) {
|
|
147
168
|
throw this.handleError(error);
|
|
148
169
|
}
|
|
149
170
|
}
|
|
150
171
|
|
|
151
172
|
/**
|
|
152
|
-
* Unrestrict a user
|
|
173
|
+
* Unrestrict a user.
|
|
174
|
+
*
|
|
175
|
+
* Busts the cached `GET /privacy/restricted` response so a remount reads the
|
|
176
|
+
* fresh list without the just-unrestricted user (see `restrictUser`).
|
|
153
177
|
* @param userId - The user ID to unrestrict
|
|
154
178
|
* @returns Success message
|
|
155
179
|
*/
|
|
@@ -158,9 +182,11 @@ export function OxyServicesPrivacyMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
158
182
|
if (!userId) {
|
|
159
183
|
throw new Error('User ID is required');
|
|
160
184
|
}
|
|
161
|
-
|
|
185
|
+
const result = await this.makeRequest<{ message: string }>('DELETE', `/privacy/restricted/${userId}`, undefined, {
|
|
162
186
|
cache: false,
|
|
163
187
|
});
|
|
188
|
+
this.clearCacheEntry('GET:/privacy/restricted');
|
|
189
|
+
return result;
|
|
164
190
|
} catch (error) {
|
|
165
191
|
throw this.handleError(error);
|
|
166
192
|
}
|
|
@@ -124,9 +124,13 @@ export function OxyServicesTopicsMixin<T extends typeof OxyServicesBase>(Base: T
|
|
|
124
124
|
}
|
|
125
125
|
): Promise<TopicData> {
|
|
126
126
|
try {
|
|
127
|
-
|
|
127
|
+
const result = await this.makeRequest<TopicData>('PATCH', `/topics/${slug}`, data, {
|
|
128
128
|
cache: false,
|
|
129
129
|
});
|
|
130
|
+
// Bust the cached topic detail so `getTopicBySlug(slug)` reflects the
|
|
131
|
+
// updated metadata immediately (it caches at the LONG TTL).
|
|
132
|
+
this.clearCacheEntry(`GET:/topics/${slug}`);
|
|
133
|
+
return result;
|
|
130
134
|
} catch (error) {
|
|
131
135
|
throw this.handleError(error);
|
|
132
136
|
}
|
|
@@ -482,16 +482,25 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
482
482
|
}
|
|
483
483
|
|
|
484
484
|
/**
|
|
485
|
-
* Update privacy settings
|
|
485
|
+
* Update privacy settings.
|
|
486
|
+
*
|
|
487
|
+
* Invalidates the cached `GET /privacy/<id>/privacy` response (the exact
|
|
488
|
+
* key `getPrivacySettings` reads, scoped to the same `id`) after the write.
|
|
489
|
+
* `getPrivacySettings` caches for ~2 minutes (identity-scoped); without
|
|
490
|
+
* busting that entry, a follow-up read within the TTL window returns the
|
|
491
|
+
* pre-update settings. `clearCacheEntry` deletes every identity-scoped
|
|
492
|
+
* variant of the key.
|
|
486
493
|
* @param settings - Partial privacy settings object
|
|
487
494
|
* @param userId - The user ID (defaults to current user)
|
|
488
495
|
*/
|
|
489
496
|
async updatePrivacySettings(settings: Partial<PrivacySettings>, userId?: string): Promise<PrivacySettings> {
|
|
490
497
|
try {
|
|
491
498
|
const id = userId || (await this.getCurrentUser()).id;
|
|
492
|
-
|
|
499
|
+
const result = await this.makeRequest<PrivacySettings>('PATCH', `/privacy/${id}/privacy`, settings, {
|
|
493
500
|
cache: false,
|
|
494
501
|
});
|
|
502
|
+
this.clearCacheEntry(`GET:/privacy/${id}/privacy`);
|
|
503
|
+
return result;
|
|
495
504
|
} catch (error) {
|
|
496
505
|
throw this.handleError(error);
|
|
497
506
|
}
|
|
@@ -140,6 +140,9 @@ export function OxyServicesWorkspacesMixin<T extends typeof OxyServicesBase>(Bas
|
|
|
140
140
|
data,
|
|
141
141
|
{ cache: false },
|
|
142
142
|
);
|
|
143
|
+
// Bust the cached workspace list so the new workspace appears on the
|
|
144
|
+
// next `getWorkspaces()` read within the TTL window.
|
|
145
|
+
this.clearCacheEntry('GET:/workspaces');
|
|
143
146
|
return res.workspace;
|
|
144
147
|
} catch (error) {
|
|
145
148
|
throw this.handleError(error);
|
|
@@ -180,6 +183,9 @@ export function OxyServicesWorkspacesMixin<T extends typeof OxyServicesBase>(Bas
|
|
|
180
183
|
data,
|
|
181
184
|
{ cache: false },
|
|
182
185
|
);
|
|
186
|
+
// Bust the cached detail and list — both surface workspace fields.
|
|
187
|
+
this.clearCacheEntry(`GET:/workspaces/${encodeURIComponent(workspaceId)}`);
|
|
188
|
+
this.clearCacheEntry('GET:/workspaces');
|
|
183
189
|
return res.workspace;
|
|
184
190
|
} catch (error) {
|
|
185
191
|
throw this.handleError(error);
|
|
@@ -192,12 +198,17 @@ export function OxyServicesWorkspacesMixin<T extends typeof OxyServicesBase>(Bas
|
|
|
192
198
|
*/
|
|
193
199
|
async deleteWorkspace(workspaceId: string): Promise<WorkspaceSuccessResult> {
|
|
194
200
|
try {
|
|
195
|
-
|
|
201
|
+
const result = await this.makeRequest<WorkspaceSuccessResult>(
|
|
196
202
|
'DELETE',
|
|
197
203
|
`/workspaces/${encodeURIComponent(workspaceId)}`,
|
|
198
204
|
undefined,
|
|
199
205
|
{ cache: false },
|
|
200
206
|
);
|
|
207
|
+
// Bust every cached representation of the deleted workspace.
|
|
208
|
+
this.clearCacheEntry(`GET:/workspaces/${encodeURIComponent(workspaceId)}`);
|
|
209
|
+
this.clearCacheEntry(`GET:/workspaces/${encodeURIComponent(workspaceId)}/members`);
|
|
210
|
+
this.clearCacheEntry('GET:/workspaces');
|
|
211
|
+
return result;
|
|
201
212
|
} catch (error) {
|
|
202
213
|
throw this.handleError(error);
|
|
203
214
|
}
|
|
@@ -239,6 +250,7 @@ export function OxyServicesWorkspacesMixin<T extends typeof OxyServicesBase>(Bas
|
|
|
239
250
|
data,
|
|
240
251
|
{ cache: false },
|
|
241
252
|
);
|
|
253
|
+
this._invalidateWorkspaceMembership(workspaceId);
|
|
242
254
|
return res.member;
|
|
243
255
|
} catch (error) {
|
|
244
256
|
throw this.handleError(error);
|
|
@@ -263,6 +275,7 @@ export function OxyServicesWorkspacesMixin<T extends typeof OxyServicesBase>(Bas
|
|
|
263
275
|
data,
|
|
264
276
|
{ cache: false },
|
|
265
277
|
);
|
|
278
|
+
this._invalidateWorkspaceMembership(workspaceId);
|
|
266
279
|
return res.member;
|
|
267
280
|
} catch (error) {
|
|
268
281
|
throw this.handleError(error);
|
|
@@ -279,12 +292,14 @@ export function OxyServicesWorkspacesMixin<T extends typeof OxyServicesBase>(Bas
|
|
|
279
292
|
memberId: string,
|
|
280
293
|
): Promise<WorkspaceSuccessResult> {
|
|
281
294
|
try {
|
|
282
|
-
|
|
295
|
+
const result = await this.makeRequest<WorkspaceSuccessResult>(
|
|
283
296
|
'DELETE',
|
|
284
297
|
`/workspaces/${encodeURIComponent(workspaceId)}/members/${encodeURIComponent(memberId)}`,
|
|
285
298
|
undefined,
|
|
286
299
|
{ cache: false },
|
|
287
300
|
);
|
|
301
|
+
this._invalidateWorkspaceMembership(workspaceId);
|
|
302
|
+
return result;
|
|
288
303
|
} catch (error) {
|
|
289
304
|
throw this.handleError(error);
|
|
290
305
|
}
|
|
@@ -301,15 +316,36 @@ export function OxyServicesWorkspacesMixin<T extends typeof OxyServicesBase>(Bas
|
|
|
301
316
|
data: TransferWorkspaceOwnershipInput,
|
|
302
317
|
): Promise<WorkspaceSuccessResult> {
|
|
303
318
|
try {
|
|
304
|
-
|
|
319
|
+
const result = await this.makeRequest<WorkspaceSuccessResult>(
|
|
305
320
|
'POST',
|
|
306
321
|
`/workspaces/${encodeURIComponent(workspaceId)}/transfer-ownership`,
|
|
307
322
|
data,
|
|
308
323
|
{ cache: false },
|
|
309
324
|
);
|
|
325
|
+
// Ownership change alters roles in the member list AND the detail, and
|
|
326
|
+
// can change which workspaces the caller "owns" in the list view.
|
|
327
|
+
this._invalidateWorkspaceMembership(workspaceId);
|
|
328
|
+
this.clearCacheEntry('GET:/workspaces');
|
|
329
|
+
return result;
|
|
310
330
|
} catch (error) {
|
|
311
331
|
throw this.handleError(error);
|
|
312
332
|
}
|
|
313
333
|
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Bust the cached member list and detail for a workspace after a membership
|
|
337
|
+
* mutation. The member list (`getWorkspaceMembers`) and the detail
|
|
338
|
+
* (`getWorkspace`, which can embed member counts) both go stale when the
|
|
339
|
+
* member set or a member's role changes.
|
|
340
|
+
*
|
|
341
|
+
* Internal helper (leading underscore); not part of the supported public
|
|
342
|
+
* surface. Public rather than `private` because mixins compose into an
|
|
343
|
+
* exported anonymous class, where TypeScript cannot represent a private
|
|
344
|
+
* member in the emitted declaration file (TS4094).
|
|
345
|
+
*/
|
|
346
|
+
_invalidateWorkspaceMembership(workspaceId: string): void {
|
|
347
|
+
this.clearCacheEntry(`GET:/workspaces/${encodeURIComponent(workspaceId)}/members`);
|
|
348
|
+
this.clearCacheEntry(`GET:/workspaces/${encodeURIComponent(workspaceId)}`);
|
|
349
|
+
}
|
|
314
350
|
};
|
|
315
351
|
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Privacy / list cache-invalidation tests.
|
|
3
|
+
*
|
|
4
|
+
* The privacy reads cache their GET responses (identity-scoped):
|
|
5
|
+
* - `getBlockedUsers()` → `GET:/privacy/blocked` (~1 min TTL)
|
|
6
|
+
* - `getRestrictedUsers()` → `GET:/privacy/restricted` (~1 min TTL)
|
|
7
|
+
* - `getPrivacySettings(id)`→ `GET:/privacy/<id>/privacy` (~2 min TTL)
|
|
8
|
+
*
|
|
9
|
+
* Each corresponding write MUST invalidate the matching cached GET, otherwise a
|
|
10
|
+
* consumer that re-reads within the TTL window observes the STALE pre-write
|
|
11
|
+
* value (mirrors the follow/unfollow follow-status invalidation contract).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { OxyServices } from '../../OxyServices';
|
|
15
|
+
|
|
16
|
+
/** Build a non-verified JWT whose payload decodes to the given claims. */
|
|
17
|
+
function makeJwt(payload: Record<string, unknown>): string {
|
|
18
|
+
const b64url = (obj: Record<string, unknown>): string =>
|
|
19
|
+
Buffer.from(JSON.stringify(obj)).toString('base64url');
|
|
20
|
+
const fullPayload = { exp: Math.floor(Date.now() / 1000) + 3600, ...payload };
|
|
21
|
+
return `${b64url({ alg: 'none', typ: 'JWT' })}.${b64url(fullPayload)}.sig`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A JSON `Response` mimicking the API's `{ data: ... }` success envelope. */
|
|
25
|
+
function jsonResponse(data: unknown): Response {
|
|
26
|
+
return new Response(JSON.stringify({ data }), {
|
|
27
|
+
status: 200,
|
|
28
|
+
headers: { 'content-type': 'application/json' },
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('privacy cache invalidation', () => {
|
|
33
|
+
let originalFetch: typeof globalThis.fetch;
|
|
34
|
+
let fetchMock: jest.Mock<Promise<Response>, [RequestInfo | URL, RequestInit?]>;
|
|
35
|
+
let oxy: OxyServices;
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
originalFetch = globalThis.fetch;
|
|
39
|
+
fetchMock = jest.fn();
|
|
40
|
+
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
41
|
+
oxy = new OxyServices({ baseURL: 'http://test.invalid' });
|
|
42
|
+
oxy.httpService.setTokens(makeJwt({ userId: 'me' }));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
globalThis.fetch = originalFetch;
|
|
47
|
+
jest.clearAllMocks();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('busts the cached blocked list after blockUser', async () => {
|
|
51
|
+
// 1) Warm the cache: empty blocked list.
|
|
52
|
+
fetchMock.mockResolvedValueOnce(jsonResponse([]));
|
|
53
|
+
expect(await oxy.getBlockedUsers()).toEqual([]);
|
|
54
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
55
|
+
|
|
56
|
+
// A second read within the TTL is a cache hit (no extra network call).
|
|
57
|
+
await oxy.getBlockedUsers();
|
|
58
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
59
|
+
|
|
60
|
+
// 2) Block a user — must invalidate the cached list.
|
|
61
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ message: 'ok' }));
|
|
62
|
+
await oxy.blockUser('target-1');
|
|
63
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
64
|
+
|
|
65
|
+
// 3) Re-read MUST re-fetch and observe the new entry.
|
|
66
|
+
fetchMock.mockResolvedValueOnce(jsonResponse([{ blockedId: 'target-1' }]));
|
|
67
|
+
const after = await oxy.getBlockedUsers();
|
|
68
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
69
|
+
expect(after).toEqual([{ blockedId: 'target-1' }]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('busts the cached blocked list after unblockUser', async () => {
|
|
73
|
+
fetchMock.mockResolvedValueOnce(jsonResponse([{ blockedId: 'target-1' }]));
|
|
74
|
+
await oxy.getBlockedUsers();
|
|
75
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
76
|
+
|
|
77
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ message: 'ok' }));
|
|
78
|
+
await oxy.unblockUser('target-1');
|
|
79
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
80
|
+
|
|
81
|
+
fetchMock.mockResolvedValueOnce(jsonResponse([]));
|
|
82
|
+
expect(await oxy.getBlockedUsers()).toEqual([]);
|
|
83
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('busts the cached restricted list after restrictUser / unrestrictUser', async () => {
|
|
87
|
+
fetchMock.mockResolvedValueOnce(jsonResponse([]));
|
|
88
|
+
await oxy.getRestrictedUsers();
|
|
89
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
90
|
+
|
|
91
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ message: 'ok' }));
|
|
92
|
+
await oxy.restrictUser('target-2');
|
|
93
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
94
|
+
|
|
95
|
+
fetchMock.mockResolvedValueOnce(jsonResponse([{ restrictedId: 'target-2' }]));
|
|
96
|
+
expect(await oxy.getRestrictedUsers()).toEqual([{ restrictedId: 'target-2' }]);
|
|
97
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
98
|
+
|
|
99
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ message: 'ok' }));
|
|
100
|
+
await oxy.unrestrictUser('target-2');
|
|
101
|
+
expect(fetchMock).toHaveBeenCalledTimes(4);
|
|
102
|
+
|
|
103
|
+
fetchMock.mockResolvedValueOnce(jsonResponse([]));
|
|
104
|
+
expect(await oxy.getRestrictedUsers()).toEqual([]);
|
|
105
|
+
expect(fetchMock).toHaveBeenCalledTimes(5);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('busts the cached privacy settings (same id) after updatePrivacySettings', async () => {
|
|
109
|
+
// Warm the settings cache for an explicit id (avoids a getCurrentUser call).
|
|
110
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ isPrivateAccount: false }));
|
|
111
|
+
expect(await oxy.getPrivacySettings('me')).toEqual({ isPrivateAccount: false });
|
|
112
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
113
|
+
|
|
114
|
+
// Cache hit on the second read.
|
|
115
|
+
await oxy.getPrivacySettings('me');
|
|
116
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
117
|
+
|
|
118
|
+
// Update — must invalidate `GET:/privacy/me/privacy`.
|
|
119
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ isPrivateAccount: true }));
|
|
120
|
+
await oxy.updatePrivacySettings({ isPrivateAccount: true }, 'me');
|
|
121
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
122
|
+
|
|
123
|
+
// Re-read MUST re-fetch and observe the new value.
|
|
124
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ isPrivateAccount: true }));
|
|
125
|
+
const after = await oxy.getPrivacySettings('me');
|
|
126
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
127
|
+
expect(after).toEqual({ isPrivateAccount: true });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('invalidates the exact logical keys on block/restrict/settings writes', async () => {
|
|
131
|
+
const clearSpy = jest.spyOn(oxy, 'clearCacheEntry');
|
|
132
|
+
|
|
133
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ message: 'ok' }));
|
|
134
|
+
await oxy.blockUser('u1');
|
|
135
|
+
expect(clearSpy).toHaveBeenCalledWith('GET:/privacy/blocked');
|
|
136
|
+
|
|
137
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ message: 'ok' }));
|
|
138
|
+
await oxy.restrictUser('u2');
|
|
139
|
+
expect(clearSpy).toHaveBeenCalledWith('GET:/privacy/restricted');
|
|
140
|
+
|
|
141
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ isPrivateAccount: true }));
|
|
142
|
+
await oxy.updatePrivacySettings({ isPrivateAccount: true }, 'me');
|
|
143
|
+
expect(clearSpy).toHaveBeenCalledWith('GET:/privacy/me/privacy');
|
|
144
|
+
|
|
145
|
+
clearSpy.mockRestore();
|
|
146
|
+
});
|
|
147
|
+
});
|
package/src/models/interfaces.ts
CHANGED
|
@@ -29,8 +29,20 @@ export interface OxyConfig {
|
|
|
29
29
|
*/
|
|
30
30
|
clientId?: string;
|
|
31
31
|
// Performance & caching options
|
|
32
|
+
/**
|
|
33
|
+
* Enable the per-instance GET response cache. Defaults to `true` (5-minute
|
|
34
|
+
* TTL). Set to `false` to disable caching entirely for this instance — GET
|
|
35
|
+
* responses are then never stored and never served from cache, so every read
|
|
36
|
+
* hits the network. Useful for a linked backend client where another layer
|
|
37
|
+
* (e.g. React Query) is the single cache authority and the SDK's own cache
|
|
38
|
+
* would otherwise serve stale data after a write.
|
|
39
|
+
*/
|
|
32
40
|
enableCache?: boolean;
|
|
33
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Cache TTL in milliseconds (default: 5 minutes). A value `<= 0` disables the
|
|
43
|
+
* per-instance GET response cache, equivalent to `enableCache: false`.
|
|
44
|
+
*/
|
|
45
|
+
cacheTTL?: number;
|
|
34
46
|
enableRequestDeduplication?: boolean;
|
|
35
47
|
enableRetry?: boolean;
|
|
36
48
|
maxRetries?: number;
|