@oxyhq/core 18.0.0 → 19.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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/i18n/locales/en-US.json +49 -5
- package/dist/cjs/i18n/locales/es-ES.json +49 -5
- package/dist/cjs/i18n/locales/locales/en-US.json +49 -5
- package/dist/cjs/i18n/locales/locales/es-ES.json +49 -5
- package/dist/cjs/index.js +17 -6
- package/dist/cjs/mixins/OxyServices.accounts.js +69 -31
- package/dist/cjs/mixins/OxyServices.followGraph.js +204 -0
- package/dist/cjs/mixins/OxyServices.user.js +17 -20
- package/dist/cjs/mixins/index.js +4 -0
- package/dist/cjs/server/index.js +8 -2
- package/dist/cjs/server/userInvalidation.js +6 -28
- package/dist/cjs/session/accountProjection.js +45 -9
- package/dist/cjs/utils/accountCacheSweep.js +80 -0
- package/dist/cjs/utils/identityCacheSweep.js +97 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/i18n/locales/en-US.json +49 -5
- package/dist/esm/i18n/locales/es-ES.json +49 -5
- package/dist/esm/i18n/locales/locales/en-US.json +49 -5
- package/dist/esm/i18n/locales/locales/es-ES.json +49 -5
- package/dist/esm/index.js +8 -2
- package/dist/esm/mixins/OxyServices.accounts.js +64 -30
- package/dist/esm/mixins/OxyServices.followGraph.js +201 -0
- package/dist/esm/mixins/OxyServices.user.js +17 -20
- package/dist/esm/mixins/index.js +4 -0
- package/dist/esm/server/index.js +5 -1
- package/dist/esm/server/userInvalidation.js +5 -26
- package/dist/esm/session/accountProjection.js +44 -9
- package/dist/esm/utils/accountCacheSweep.js +75 -0
- package/dist/esm/utils/identityCacheSweep.js +92 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +3 -3
- package/dist/types/mixins/OxyServices.accounts.d.ts +91 -34
- package/dist/types/mixins/OxyServices.followGraph.d.ts +211 -0
- package/dist/types/mixins/OxyServices.user.d.ts +10 -7
- package/dist/types/mixins/index.d.ts +2 -1
- package/dist/types/models/interfaces.d.ts +11 -3
- package/dist/types/server/index.d.ts +4 -2
- package/dist/types/server/userInvalidation.d.ts +5 -24
- package/dist/types/session/accountProjection.d.ts +38 -4
- package/dist/types/utils/accountCacheSweep.d.ts +75 -0
- package/dist/types/utils/identityCacheSweep.d.ts +80 -0
- package/package.json +2 -2
- package/src/i18n/locales/en-US.json +49 -5
- package/src/i18n/locales/es-ES.json +49 -5
- package/src/index.ts +15 -2
- package/src/mixins/OxyServices.accounts.ts +123 -45
- package/src/mixins/OxyServices.followGraph.ts +266 -0
- package/src/mixins/OxyServices.user.ts +17 -20
- package/src/mixins/__tests__/accounts.test.ts +5 -0
- package/src/mixins/__tests__/followGraph.test.ts +128 -0
- package/src/mixins/__tests__/identityWriteCacheInvalidation.test.ts +407 -0
- package/src/mixins/index.ts +5 -0
- package/src/models/interfaces.ts +11 -3
- package/src/server/__tests__/userInvalidation.test.ts +3 -20
- package/src/server/index.ts +5 -2
- package/src/server/userInvalidation.ts +8 -36
- package/src/session/__tests__/accountProjection.test.ts +109 -2
- package/src/session/accountProjection.ts +47 -9
- package/src/utils/__tests__/identityCacheSweep.test.ts +151 -0
- package/src/utils/accountCacheSweep.ts +93 -0
- package/src/utils/identityCacheSweep.ts +104 -0
|
@@ -33,10 +33,12 @@
|
|
|
33
33
|
* registers the switched session into the operator's device-set directly).
|
|
34
34
|
*/
|
|
35
35
|
import type { User } from '../models/interfaces';
|
|
36
|
-
import type {
|
|
36
|
+
import type { AccountCategoryId, AccountKind, ChildAccountKind } from '@oxyhq/contracts';
|
|
37
37
|
import type { SessionLoginResponse } from '../models/session';
|
|
38
38
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
39
39
|
import { normalizeUserIdentity } from '../utils/userIdentity';
|
|
40
|
+
import { evictOxyIdentityCache } from '../utils/identityCacheSweep';
|
|
41
|
+
import { evictOxyAccountForestCache, oxyAccountDetailCacheKey, OXY_ACCOUNT_PER_ACCOUNT_CACHE_PREFIX } from '../utils/accountCacheSweep';
|
|
40
42
|
import { CACHE_TIMES } from './mixinHelpers';
|
|
41
43
|
|
|
42
44
|
// ---------------------------------------------------------------------------
|
|
@@ -52,8 +54,16 @@ import { CACHE_TIMES } from './mixinHelpers';
|
|
|
52
54
|
*
|
|
53
55
|
* Single source of truth is `@oxyhq/contracts`.
|
|
54
56
|
*/
|
|
55
|
-
export type {
|
|
56
|
-
export {
|
|
57
|
+
export type { AccountCategoryId, AccountKind } from '@oxyhq/contracts';
|
|
58
|
+
export {
|
|
59
|
+
ACCOUNT_CATEGORY_IDS,
|
|
60
|
+
ACCOUNT_KINDS,
|
|
61
|
+
MAX_ACCOUNT_CATEGORIES,
|
|
62
|
+
SELECTABLE_ACCOUNT_CATEGORY_IDS,
|
|
63
|
+
isActAsEligibleKind,
|
|
64
|
+
isSelectableAccountCategoryId,
|
|
65
|
+
kindAcceptsAccountCategories,
|
|
66
|
+
} from '@oxyhq/contracts';
|
|
57
67
|
|
|
58
68
|
/**
|
|
59
69
|
* The calling user's relationship to an account node, as resolved by the API:
|
|
@@ -78,8 +88,8 @@ export type AccountMemberStatus = 'active' | 'invited' | 'removed';
|
|
|
78
88
|
export type AccountMemberSource = 'direct' | 'inherited';
|
|
79
89
|
|
|
80
90
|
/**
|
|
81
|
-
* Client-facing AccountMember shape. `permissions` is
|
|
82
|
-
*
|
|
91
|
+
* Client-facing AccountMember shape. `permissions` is the effective permission
|
|
92
|
+
* set (role baseline plus `permissionGrants` minus `permissionRevokes`).
|
|
83
93
|
*/
|
|
84
94
|
export interface AccountMember {
|
|
85
95
|
_id: string;
|
|
@@ -89,6 +99,10 @@ export interface AccountMember {
|
|
|
89
99
|
memberUserId: string;
|
|
90
100
|
role: AccountRole;
|
|
91
101
|
permissions: string[];
|
|
102
|
+
/** Permissions granted beyond the role baseline. */
|
|
103
|
+
permissionGrants?: string[];
|
|
104
|
+
/** Permissions revoked from the role baseline. */
|
|
105
|
+
permissionRevokes?: string[];
|
|
92
106
|
/**
|
|
93
107
|
* Whether this membership cascades to the account's subtree. `true` (default)
|
|
94
108
|
* lets descendants inherit this role unless a nearer row overrides it; `false`
|
|
@@ -97,12 +111,20 @@ export interface AccountMember {
|
|
|
97
111
|
inherit: boolean;
|
|
98
112
|
status: AccountMemberStatus;
|
|
99
113
|
/**
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
114
|
+
* Where this membership COMES FROM relative to the account it is being
|
|
115
|
+
* reported for: `direct` when the row lives on that account, `inherited` when
|
|
116
|
+
* it lives on an ancestor whose `inherit` flag cascades it down.
|
|
117
|
+
*
|
|
118
|
+
* Present on every membership the API serialises — a resolved
|
|
119
|
+
* `callerMembership` and every entry of a member list alike. It is not
|
|
120
|
+
* decoration: an `inherited` entry's `accountId` is the ANCESTOR's, and the
|
|
121
|
+
* member-mutation endpoints are scoped to rows on the account named in the
|
|
122
|
+
* path, so `PATCH`/`DELETE .../members/<that row's _id>` against the
|
|
123
|
+
* descendant 404s. **Branch on `source === 'direct'` before offering to edit,
|
|
124
|
+
* remove or transfer to a member**, and count owners for a last-owner check
|
|
125
|
+
* over direct entries only.
|
|
104
126
|
*/
|
|
105
|
-
source
|
|
127
|
+
source: AccountMemberSource;
|
|
106
128
|
invitedByUserId?: string | null;
|
|
107
129
|
joinedAt?: string | null;
|
|
108
130
|
createdAt: string;
|
|
@@ -179,8 +201,17 @@ export interface CreateAccountInput {
|
|
|
179
201
|
name?: { first?: string; last?: string; displayName?: string };
|
|
180
202
|
bio?: string;
|
|
181
203
|
avatar?: string;
|
|
182
|
-
/**
|
|
183
|
-
|
|
204
|
+
/**
|
|
205
|
+
* What the account is about. ORDERED — the FIRST element is the primary
|
|
206
|
+
* category, so a picker must submit them in the order the user arranged them
|
|
207
|
+
* and must not sort. Stable ids, never labels: render each one through the
|
|
208
|
+
* `accounts.accountCategory.<id>` translation key.
|
|
209
|
+
*
|
|
210
|
+
* Offer `SELECTABLE_ACCOUNT_CATEGORY_IDS`, not `ACCOUNT_CATEGORY_IDS` — the
|
|
211
|
+
* latter still contains withdrawn ids so that accounts already carrying one
|
|
212
|
+
* keep working. At most `MAX_ACCOUNT_CATEGORIES`, no duplicates.
|
|
213
|
+
*/
|
|
214
|
+
accountCategories?: AccountCategoryId[];
|
|
184
215
|
}
|
|
185
216
|
|
|
186
217
|
/** Input accepted by `updateAccount`. Tree placement changes go through `/move`. */
|
|
@@ -195,8 +226,19 @@ export interface UpdateAccountInput {
|
|
|
195
226
|
name?: { first?: string; last?: string; displayName?: string };
|
|
196
227
|
bio?: string | null;
|
|
197
228
|
avatar?: string | null;
|
|
198
|
-
/**
|
|
199
|
-
|
|
229
|
+
/**
|
|
230
|
+
* Replaces the WHOLE list, in the order given — there is no add/remove verb,
|
|
231
|
+
* because a partial edit cannot express a re-ordering and the order is what
|
|
232
|
+
* names the primary category. `[]` clears it.
|
|
233
|
+
*
|
|
234
|
+
* Not nullable, unlike `bio` and `avatar`: the empty case already has a
|
|
235
|
+
* spelling of its own, so a second one could only ever disagree with it.
|
|
236
|
+
*
|
|
237
|
+
* Rejected for a `personal` account, and rejected when it ADDS a withdrawn
|
|
238
|
+
* id the account did not already carry — keeping or re-ordering one it has is
|
|
239
|
+
* always allowed.
|
|
240
|
+
*/
|
|
241
|
+
accountCategories?: AccountCategoryId[];
|
|
200
242
|
}
|
|
201
243
|
|
|
202
244
|
/** Input accepted by `provisionChannelAccount` (service token + `accounts:provision`). */
|
|
@@ -235,7 +277,10 @@ export interface InviteAccountMemberInput {
|
|
|
235
277
|
|
|
236
278
|
/** Input accepted by `updateAccountMember`. The owner role cannot be assigned. */
|
|
237
279
|
export interface UpdateAccountMemberInput {
|
|
238
|
-
role
|
|
280
|
+
role?: Exclude<AccountRole, 'owner'>;
|
|
281
|
+
inherit?: boolean;
|
|
282
|
+
permissionGrants?: string[];
|
|
283
|
+
permissionRevokes?: string[];
|
|
239
284
|
}
|
|
240
285
|
|
|
241
286
|
/** Input accepted by `transferAccountOwnership`. */
|
|
@@ -673,7 +718,7 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
673
718
|
);
|
|
674
719
|
// A new account changes the accessible forest — bust every cached list
|
|
675
720
|
// (flat + tree) so it appears on the next `listAccounts()` read.
|
|
676
|
-
this
|
|
721
|
+
evictOxyAccountForestCache(this);
|
|
677
722
|
return res.account;
|
|
678
723
|
} catch (error) {
|
|
679
724
|
throw this.handleError(error);
|
|
@@ -736,6 +781,18 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
736
781
|
/**
|
|
737
782
|
* Update an account's mutable profile fields. Tree placement changes
|
|
738
783
|
* (reparenting) go through the dedicated move endpoint, not here.
|
|
784
|
+
*
|
|
785
|
+
* An account IS a user, so this write changes identity — and a profile
|
|
786
|
+
* screen never reads `/accounts/<id>`. It reads `GET /users/<id>` and
|
|
787
|
+
* `GET /profiles/username/<handle>`, both cached for 5 minutes in the
|
|
788
|
+
* CALLER'S OWN process, so busting only the account-graph keys left every
|
|
789
|
+
* profile surface serving the pre-edit avatar and name for the full TTL
|
|
790
|
+
* with a perfectly healthy server (the cross-service `oxy:user:invalidate`
|
|
791
|
+
* signal does not help: it evicts BACKEND caches, and cannot reach a cache
|
|
792
|
+
* living in a browser tab). {@link evictOxyIdentityCache} owns that key
|
|
793
|
+
* list — see its docs for why the handle-keyed entries are prefix-swept
|
|
794
|
+
* (a RENAME leaves the old handle's entry unreachable by any targeted key).
|
|
795
|
+
*
|
|
739
796
|
* @param accountId - The account's Mongo `_id`.
|
|
740
797
|
* @param data - Subset of updatable profile fields.
|
|
741
798
|
*/
|
|
@@ -752,8 +809,17 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
752
809
|
);
|
|
753
810
|
// Bust the cached detail and every list (which embeds account profile
|
|
754
811
|
// data) so neither serves the pre-update snapshot.
|
|
755
|
-
this
|
|
756
|
-
this
|
|
812
|
+
evictOxyAccountForestCache(this, accountId);
|
|
813
|
+
// The parent's children list embeds this account's profile and is keyed
|
|
814
|
+
// by the PARENT id, so it is reachable only from the response node.
|
|
815
|
+
const parentAccountId = res.account?.parentAccountId;
|
|
816
|
+
if (parentAccountId) {
|
|
817
|
+
this.clearCacheEntry(
|
|
818
|
+
`GET:/accounts/${encodeURIComponent(parentAccountId)}/children`,
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
// Every identity read of this account, whichever key it lands under.
|
|
822
|
+
evictOxyIdentityCache(this, accountId);
|
|
757
823
|
return res.account;
|
|
758
824
|
} catch (error) {
|
|
759
825
|
throw this.handleError(error);
|
|
@@ -775,10 +841,9 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
775
841
|
{ cache: false },
|
|
776
842
|
);
|
|
777
843
|
// Bust every cached representation of the archived account.
|
|
778
|
-
this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}`);
|
|
779
844
|
this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}/members`);
|
|
780
845
|
this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}/credentials`);
|
|
781
|
-
this
|
|
846
|
+
evictOxyAccountForestCache(this, accountId);
|
|
782
847
|
return result;
|
|
783
848
|
} catch (error) {
|
|
784
849
|
throw this.handleError(error);
|
|
@@ -808,7 +873,27 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
808
873
|
// =========================================================================
|
|
809
874
|
|
|
810
875
|
/**
|
|
811
|
-
* List members of an account
|
|
876
|
+
* List the members of an account: the membership rows ON it, plus the rows
|
|
877
|
+
* on its ancestors that cascade into it. Each entry carries `source`
|
|
878
|
+
* (`direct` | `inherited`) saying which it is.
|
|
879
|
+
*
|
|
880
|
+
* Inherited entries are members in every sense the server enforces — an
|
|
881
|
+
* ancestor row with `inherit: true` resolves through
|
|
882
|
+
* `resolveEffectiveAccess` and confers every account permission on the
|
|
883
|
+
* descendant, `account:act_as` included — so a roster that omitted them
|
|
884
|
+
* answered `[]` for accounts several people could act on.
|
|
885
|
+
*
|
|
886
|
+
* Two things follow for a caller. An entry's `accountId` is the account its
|
|
887
|
+
* ROW lives on, so an inherited entry names an ancestor rather than the
|
|
888
|
+
* account you asked about; and the member-mutation endpoints only accept
|
|
889
|
+
* rows on the account in the path, so gate any edit/remove/transfer
|
|
890
|
+
* affordance on `source === 'direct'`.
|
|
891
|
+
*
|
|
892
|
+
* Asking what the CALLER holds over an account is a different question, and
|
|
893
|
+
* scanning this list for yourself is the wrong way to answer it — use
|
|
894
|
+
* {@link OxyServicesAccountsMixin.getAccount}, whose `callerMembership` is
|
|
895
|
+
* the server's own resolution.
|
|
896
|
+
*
|
|
812
897
|
* @param accountId - The account's Mongo `_id`.
|
|
813
898
|
*/
|
|
814
899
|
async listAccountMembers(accountId: string): Promise<AccountMember[]> {
|
|
@@ -917,7 +1002,7 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
917
1002
|
// Ownership change alters roles in the member list AND the detail, and
|
|
918
1003
|
// can change which accounts the caller "owns" in the list view.
|
|
919
1004
|
this._invalidateAccountMembership(accountId);
|
|
920
|
-
this
|
|
1005
|
+
evictOxyAccountForestCache(this);
|
|
921
1006
|
return result;
|
|
922
1007
|
} catch (error) {
|
|
923
1008
|
throw this.handleError(error);
|
|
@@ -1259,36 +1344,29 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
1259
1344
|
// =========================================================================
|
|
1260
1345
|
|
|
1261
1346
|
/**
|
|
1262
|
-
* Bust
|
|
1263
|
-
*
|
|
1264
|
-
*
|
|
1265
|
-
*
|
|
1266
|
-
* unscoped entry plus every `?`-query variant via a prefix sweep. The prefix
|
|
1267
|
-
* `GET:/accounts?` matches only the query-string list variants, never the
|
|
1268
|
-
* `GET:/accounts/<id>…` detail/sub-resource keys.
|
|
1347
|
+
* Bust the cached member list and detail for an account after a membership
|
|
1348
|
+
* mutation. The member list (`listAccountMembers`) and the detail
|
|
1349
|
+
* (`getAccount`, which can embed the caller's membership) both go stale when
|
|
1350
|
+
* the member set or a member's role changes.
|
|
1269
1351
|
*
|
|
1270
1352
|
* Internal helper (leading underscore); not part of the supported public
|
|
1271
1353
|
* surface. Public rather than `private` because mixins compose into an
|
|
1272
1354
|
* exported anonymous class, where TypeScript cannot represent a private
|
|
1273
1355
|
* member in the emitted declaration file (TS4094).
|
|
1274
|
-
*/
|
|
1275
|
-
_invalidateAccountLists(): void {
|
|
1276
|
-
this.clearCacheEntry('GET:/accounts');
|
|
1277
|
-
this.clearCacheByPrefix('GET:/accounts?');
|
|
1278
|
-
}
|
|
1279
|
-
|
|
1280
|
-
/**
|
|
1281
|
-
* Bust the cached member list and detail for an account after a membership
|
|
1282
|
-
* mutation. The member list (`listAccountMembers`) and the detail
|
|
1283
|
-
* (`getAccount`, which can embed the caller's membership) both go stale when
|
|
1284
|
-
* the member set or a member's role changes.
|
|
1285
1356
|
*
|
|
1286
|
-
*
|
|
1287
|
-
*
|
|
1357
|
+
* The forest keys themselves are NOT owned here — see
|
|
1358
|
+
* `utils/accountCacheSweep`, which the user mixin has to reach as well.
|
|
1288
1359
|
*/
|
|
1289
1360
|
_invalidateAccountMembership(accountId: string): void {
|
|
1290
1361
|
this.clearCacheEntry(`GET:/accounts/${encodeURIComponent(accountId)}/members`);
|
|
1291
|
-
this.clearCacheEntry(
|
|
1362
|
+
this.clearCacheEntry(oxyAccountDetailCacheKey(accountId));
|
|
1363
|
+
// Inherited rows on descendant rosters are derived from this account's
|
|
1364
|
+
// membership table — a targeted clear of only the mutated account's keys
|
|
1365
|
+
// leaves every other cached `…/members` list serving stale inherited
|
|
1366
|
+
// roles until MEDIUM TTL. Sweep all per-account sub-resource keys instead;
|
|
1367
|
+
// the forest list keys (`GET:/accounts`, `GET:/accounts?…`) are excluded
|
|
1368
|
+
// by the trailing slash on the prefix (see accountCacheSweep).
|
|
1369
|
+
this.clearCacheByPrefix(OXY_ACCOUNT_PER_ACCOUNT_CACHE_PREFIX);
|
|
1292
1370
|
}
|
|
1293
1371
|
|
|
1294
1372
|
/**
|
|
@@ -1300,8 +1378,8 @@ export function OxyServicesAccountsMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
1300
1378
|
* query-string list variants, never the `GET:/applications/<id>…`
|
|
1301
1379
|
* detail/sub-resource keys.
|
|
1302
1380
|
*
|
|
1303
|
-
* Internal helper (leading underscore); see `
|
|
1304
|
-
* this is public rather than `private`.
|
|
1381
|
+
* Internal helper (leading underscore); see `_invalidateAccountMembership`
|
|
1382
|
+
* for why this is public rather than `private`.
|
|
1305
1383
|
*/
|
|
1306
1384
|
_invalidateAppLists(): void {
|
|
1307
1385
|
this.clearCacheEntry('GET:/applications');
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Follow Graph Mixin (`/v2/follows`)
|
|
3
|
+
*
|
|
4
|
+
* The user-owned follow graph: one relationship per user and target, shared by
|
|
5
|
+
* every application, with per-application context on top. This is the SDK half
|
|
6
|
+
* of #809 and the replacement for the per-app follow endpoints each application
|
|
7
|
+
* grew for itself.
|
|
8
|
+
*
|
|
9
|
+
* ## Why this is not `followUser` with more parameters
|
|
10
|
+
*
|
|
11
|
+
* `followUser` answers "does A follow B" and nothing else. This answers "what
|
|
12
|
+
* does this user follow, anywhere, and which applications act on it" — a
|
|
13
|
+
* different question with a different owner. The legacy methods stay for the
|
|
14
|
+
* Mongo-backed social graph they were written for; new kinds (topics, stores,
|
|
15
|
+
* artists, channels) come here, and users will migrate behind an adapter rather
|
|
16
|
+
* than through a flag day.
|
|
17
|
+
*
|
|
18
|
+
* ## Caching
|
|
19
|
+
*
|
|
20
|
+
* Every method is `cache: false`. A follow status is exactly the shape that
|
|
21
|
+
* must never be served stale: the SDK's GET cache is identity-scoped but
|
|
22
|
+
* time-based, and a status cached across a write is the "follow reverts after
|
|
23
|
+
* navigating away and back" bug — which the legacy `followUser` had to fix with
|
|
24
|
+
* explicit invalidation. Not caching at this layer means an app's own store
|
|
25
|
+
* (React Query, Zustand) is the single cache authority, which is the rule the
|
|
26
|
+
* ecosystem already follows for anything written and read in the same session.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type {
|
|
30
|
+
FollowListPage,
|
|
31
|
+
FollowMutation,
|
|
32
|
+
FollowOptions,
|
|
33
|
+
FollowStatus,
|
|
34
|
+
UnfollowMutation,
|
|
35
|
+
} from '@oxyhq/contracts';
|
|
36
|
+
import type { OxyServicesBase } from '../OxyServices.base';
|
|
37
|
+
import { buildUrl } from '../utils/apiUtils';
|
|
38
|
+
|
|
39
|
+
export function OxyServicesFollowGraphMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
40
|
+
return class extends Base {
|
|
41
|
+
constructor(...args: any[]) {
|
|
42
|
+
super(...(args as [any]));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Follow a target. Idempotent — following something already followed
|
|
47
|
+
* returns the same relationship with `created: false`.
|
|
48
|
+
*
|
|
49
|
+
* The follower and the acting application are BOTH derived server-side from
|
|
50
|
+
* the session. There is deliberately no parameter for either: a client that
|
|
51
|
+
* could name them could forge a follow on another user's behalf, or record
|
|
52
|
+
* one as coming from an application it is not.
|
|
53
|
+
*
|
|
54
|
+
* @param targetId - The registered target's id, not its URI. Registration is
|
|
55
|
+
* a separate operation precisely so following cannot silently create
|
|
56
|
+
* targets — a typo would otherwise become a permanent row nobody follows.
|
|
57
|
+
* @param options.expiresIn - Seconds until the follow lapses on its own. For
|
|
58
|
+
* an event, a trial, a topic followed for a week. The server bounds it.
|
|
59
|
+
*/
|
|
60
|
+
async followTarget(targetId: string, options?: FollowOptions): Promise<FollowMutation> {
|
|
61
|
+
try {
|
|
62
|
+
return await this.makeRequest<FollowMutation>(
|
|
63
|
+
'PUT',
|
|
64
|
+
`/v2/follows/${encodeURIComponent(targetId)}`,
|
|
65
|
+
options?.expiresIn !== undefined ? { expiresIn: options.expiresIn } : {},
|
|
66
|
+
{ cache: false },
|
|
67
|
+
);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
throw this.handleError(error);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Unfollow everywhere.
|
|
75
|
+
*
|
|
76
|
+
* There is no "unfollow here" — that is `setFollowApplicationMode(...,
|
|
77
|
+
* 'disabled')`, and keeping the two distinct is the point of the design. An
|
|
78
|
+
* application that quietly turned a global unfollow into a local one would
|
|
79
|
+
* leave the user believing they had stopped following something they still
|
|
80
|
+
* follow everywhere else.
|
|
81
|
+
*
|
|
82
|
+
* Idempotent: `removed: false` when it was already gone, because the state
|
|
83
|
+
* the caller asked for is the state that holds.
|
|
84
|
+
*/
|
|
85
|
+
async unfollowTarget(relationshipId: string): Promise<UnfollowMutation> {
|
|
86
|
+
try {
|
|
87
|
+
return await this.makeRequest<UnfollowMutation>(
|
|
88
|
+
'DELETE',
|
|
89
|
+
`/v2/follows/${encodeURIComponent(relationshipId)}`,
|
|
90
|
+
undefined,
|
|
91
|
+
{ cache: false },
|
|
92
|
+
);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
throw this.handleError(error);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The three-part status: globally, in this application, and in effect.
|
|
100
|
+
*
|
|
101
|
+
* Render `effectiveState` on the button and keep the other two for the
|
|
102
|
+
* explanation. A UI that collapses them cannot tell the user why a follow
|
|
103
|
+
* they can see in their list is not showing up in this app's feed.
|
|
104
|
+
*/
|
|
105
|
+
async getFollowTargetStatus(targetId: string): Promise<FollowStatus> {
|
|
106
|
+
try {
|
|
107
|
+
return await this.makeRequest<FollowStatus>(
|
|
108
|
+
'GET',
|
|
109
|
+
`/v2/follows/${encodeURIComponent(targetId)}/status`,
|
|
110
|
+
undefined,
|
|
111
|
+
{ cache: false },
|
|
112
|
+
);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
throw this.handleError(error);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Turn a relationship off, or back on, in ONE application.
|
|
120
|
+
*
|
|
121
|
+
* Omit `applicationId` and it applies to the calling application, which is
|
|
122
|
+
* the only form an ordinary app should ever need. Naming a DIFFERENT
|
|
123
|
+
* application requires `follows:manage` server-side — acting on another
|
|
124
|
+
* app's behalf is exactly the cross-application authority this design
|
|
125
|
+
* otherwise refuses, so it is a distinct permission and not a parameter an
|
|
126
|
+
* app happens to fill in.
|
|
127
|
+
*/
|
|
128
|
+
async setFollowApplicationMode(
|
|
129
|
+
relationshipId: string,
|
|
130
|
+
mode: 'enabled' | 'disabled',
|
|
131
|
+
applicationId?: string,
|
|
132
|
+
): Promise<{ ok: true; mode: 'enabled' | 'disabled' }> {
|
|
133
|
+
try {
|
|
134
|
+
return await this.makeRequest(
|
|
135
|
+
'PUT',
|
|
136
|
+
`/v2/follows/${encodeURIComponent(relationshipId)}/context`,
|
|
137
|
+
{ mode, ...(applicationId ? { applicationId } : {}) },
|
|
138
|
+
{ cache: false },
|
|
139
|
+
);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
throw this.handleError(error);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Drop the override so this application follows the global relationship
|
|
147
|
+
* again. Distinct from setting `enabled`: inheriting means a later global
|
|
148
|
+
* change takes effect here, and an explicit `enabled` means it does not.
|
|
149
|
+
*/
|
|
150
|
+
async restoreFollowInheritance(
|
|
151
|
+
relationshipId: string,
|
|
152
|
+
applicationId?: string,
|
|
153
|
+
): Promise<{ ok: true }> {
|
|
154
|
+
try {
|
|
155
|
+
const path = buildUrl(
|
|
156
|
+
`/v2/follows/${encodeURIComponent(relationshipId)}/context`,
|
|
157
|
+
applicationId ? { applicationId } : {},
|
|
158
|
+
);
|
|
159
|
+
return await this.makeRequest('DELETE', path, undefined, { cache: false });
|
|
160
|
+
} catch (error) {
|
|
161
|
+
throw this.handleError(error);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Resolve a target by canonical URI, registering it the first time anyone
|
|
167
|
+
* asks. The call an application makes on the way into a screen, before it
|
|
168
|
+
* can render a button.
|
|
169
|
+
*
|
|
170
|
+
* Idempotent on the URI, which is what makes two applications describing
|
|
171
|
+
* the same thing — the same fediverse actor, the same topic — arrive at ONE
|
|
172
|
+
* row, and therefore at one relationship per user rather than one per app.
|
|
173
|
+
*
|
|
174
|
+
* `metadata` is a display snapshot (name, handle, icon) and is refreshed
|
|
175
|
+
* only for the application that provides the target: a second application
|
|
176
|
+
* passing its own idea of the name would make the display flip depending on
|
|
177
|
+
* which app last looked.
|
|
178
|
+
*/
|
|
179
|
+
async ensureFollowTarget(input: {
|
|
180
|
+
uri: string;
|
|
181
|
+
kind: string;
|
|
182
|
+
metadata?: Record<string, unknown>;
|
|
183
|
+
providerReference?: string;
|
|
184
|
+
localUserId?: string;
|
|
185
|
+
}): Promise<{ id: string; uri: string; kind: string; created: boolean }> {
|
|
186
|
+
try {
|
|
187
|
+
return await this.makeRequest('POST', '/v2/follow-targets', input, { cache: false });
|
|
188
|
+
} catch (error) {
|
|
189
|
+
throw this.handleError(error);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Claim a namespace for the calling application. First come, and idempotent
|
|
195
|
+
* for the holder — an application that registers on every boot must not
|
|
196
|
+
* fail the second time.
|
|
197
|
+
*/
|
|
198
|
+
async claimFollowNamespace(
|
|
199
|
+
namespace: string
|
|
200
|
+
): Promise<{ namespace: string; created: boolean }> {
|
|
201
|
+
try {
|
|
202
|
+
return await this.makeRequest(
|
|
203
|
+
'POST',
|
|
204
|
+
'/v2/follow-targets/namespaces',
|
|
205
|
+
{ namespace },
|
|
206
|
+
{ cache: false },
|
|
207
|
+
);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
throw this.handleError(error);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Declare what following a kind of thing MEANS: the verb clients render,
|
|
215
|
+
* whether reverse lookups are public, whether it federates.
|
|
216
|
+
*
|
|
217
|
+
* Declared once by the application that owns the concept, rather than
|
|
218
|
+
* passed per call site — otherwise two screens of one app can disagree
|
|
219
|
+
* about whether a store is followed or subscribed to.
|
|
220
|
+
*/
|
|
221
|
+
async registerFollowKind(input: {
|
|
222
|
+
kind: string;
|
|
223
|
+
label?: string;
|
|
224
|
+
capabilities?: {
|
|
225
|
+
verb?: 'follow' | 'subscribe' | 'join';
|
|
226
|
+
reverse?: 'public' | 'private' | 'aggregate' | 'unavailable';
|
|
227
|
+
federated?: boolean;
|
|
228
|
+
};
|
|
229
|
+
}): Promise<{ kind: string; created: boolean }> {
|
|
230
|
+
try {
|
|
231
|
+
return await this.makeRequest('POST', '/v2/follow-targets/kinds', input, {
|
|
232
|
+
cache: false,
|
|
233
|
+
});
|
|
234
|
+
} catch (error) {
|
|
235
|
+
throw this.handleError(error);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Everything the signed-in user follows, newest first.
|
|
241
|
+
*
|
|
242
|
+
* Owner-only by construction server-side — there is no parameter naming a
|
|
243
|
+
* user, so this cannot be pointed at somebody else's graph.
|
|
244
|
+
*
|
|
245
|
+
* Paginate by passing back `nextCursor`, never an offset: the list changes
|
|
246
|
+
* while it is being read, and an offset silently skips or repeats rows
|
|
247
|
+
* exactly when it does.
|
|
248
|
+
*/
|
|
249
|
+
async listFollows(params?: {
|
|
250
|
+
kind?: string;
|
|
251
|
+
cursor?: string;
|
|
252
|
+
limit?: number;
|
|
253
|
+
}): Promise<FollowListPage> {
|
|
254
|
+
try {
|
|
255
|
+
const path = buildUrl('/v2/me/follows', {
|
|
256
|
+
...(params?.kind ? { kind: params.kind } : {}),
|
|
257
|
+
...(params?.cursor ? { cursor: params.cursor } : {}),
|
|
258
|
+
...(params?.limit ? { limit: params.limit } : {}),
|
|
259
|
+
});
|
|
260
|
+
return await this.makeRequest<FollowListPage>('GET', path, undefined, { cache: false });
|
|
261
|
+
} catch (error) {
|
|
262
|
+
throw this.handleError(error);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
}
|
|
@@ -28,6 +28,8 @@ import {
|
|
|
28
28
|
import { KeyManager } from '../crypto/keyManager';
|
|
29
29
|
import { SignatureService } from '../crypto/signatureService';
|
|
30
30
|
import { normalizeUserIdentity, normalizeUserIdentityOrNull } from '../utils/userIdentity';
|
|
31
|
+
import { evictOxyIdentityCache } from '../utils/identityCacheSweep';
|
|
32
|
+
import { evictOxyAccountForestCache } from '../utils/accountCacheSweep';
|
|
31
33
|
import { logger } from '../logger';
|
|
32
34
|
import { extractErrorStatus } from '../utils/errorUtils';
|
|
33
35
|
|
|
@@ -534,13 +536,16 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
534
536
|
/**
|
|
535
537
|
* Update user profile.
|
|
536
538
|
*
|
|
537
|
-
* Invalidates the SDK-side response cache for every endpoint that
|
|
538
|
-
*
|
|
539
|
-
*
|
|
540
|
-
*
|
|
541
|
-
*
|
|
542
|
-
*
|
|
543
|
-
*
|
|
539
|
+
* Invalidates the SDK-side response cache for every endpoint that can
|
|
540
|
+
* return this user — the list is owned by {@link evictOxyIdentityCache}, so
|
|
541
|
+
* a new identity read is added in one place instead of to each writer
|
|
542
|
+
* separately (this method's own hand-written copy had already drifted from
|
|
543
|
+
* the server-side one, missing `GET /auth/lookup/*` and
|
|
544
|
+
* `GET /profiles/resolve`). The account forest (`GET /accounts` and the
|
|
545
|
+
* caller's own detail row) is swept too — a personal account IS this user,
|
|
546
|
+
* and `AccountNode.account` embeds the whole profile — from the list that
|
|
547
|
+
* {@link evictOxyAccountForestCache} owns, for the same reason: the accounts
|
|
548
|
+
* mixin writes those keys as well, and two hand-written copies drift.
|
|
544
549
|
*
|
|
545
550
|
* TanStack Query handles offline queuing automatically.
|
|
546
551
|
*/
|
|
@@ -550,15 +555,8 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
550
555
|
await this.makeRequest<User>('PUT', '/users/me', updates, { cache: false }),
|
|
551
556
|
);
|
|
552
557
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
// tracks the set of active session IDs centrally.
|
|
556
|
-
this.clearCacheByPrefix('GET:/session/user/');
|
|
557
|
-
this.clearCacheByPrefix('GET:/users/me');
|
|
558
|
-
this.clearCacheByPrefix('GET:/profiles/username/');
|
|
559
|
-
if (result?.id) {
|
|
560
|
-
this.clearCacheEntry(`GET:/users/${result.id}`);
|
|
561
|
-
}
|
|
558
|
+
evictOxyIdentityCache(this, result?.id);
|
|
559
|
+
evictOxyAccountForestCache(this, result?.id);
|
|
562
560
|
|
|
563
561
|
return result;
|
|
564
562
|
} catch (error) {
|
|
@@ -615,10 +613,9 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
615
613
|
const result = await this.makeRequest<PrivacySettings>('PATCH', `/privacy/${id}/privacy`, settings, {
|
|
616
614
|
cache: false,
|
|
617
615
|
});
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
this
|
|
621
|
-
this.clearCacheEntry(`GET:/users/${id}`);
|
|
616
|
+
// Privacy settings ride the user DTO, so every identity read goes stale
|
|
617
|
+
// too — same key list as any other profile write.
|
|
618
|
+
evictOxyIdentityCache(this, id);
|
|
622
619
|
this.clearCacheEntry(`GET:/privacy/${id}/privacy`);
|
|
623
620
|
return result;
|
|
624
621
|
} catch (error) {
|
|
@@ -47,6 +47,7 @@ const memberFixture: AccountMember = {
|
|
|
47
47
|
permissions: ['account:read', 'apps:read'],
|
|
48
48
|
inherit: true,
|
|
49
49
|
status: 'active',
|
|
50
|
+
source: 'direct',
|
|
50
51
|
createdAt: '2026-06-29T00:00:00.000Z',
|
|
51
52
|
updatedAt: '2026-06-29T00:00:00.000Z',
|
|
52
53
|
};
|
|
@@ -385,6 +386,7 @@ describe('OxyServices.accounts', () => {
|
|
|
385
386
|
);
|
|
386
387
|
expect(clearEntrySpy).toHaveBeenCalledWith('GET:/accounts/acc1/members');
|
|
387
388
|
expect(clearEntrySpy).toHaveBeenCalledWith('GET:/accounts/acc1');
|
|
389
|
+
expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/accounts/');
|
|
388
390
|
});
|
|
389
391
|
});
|
|
390
392
|
|
|
@@ -403,6 +405,7 @@ describe('OxyServices.accounts', () => {
|
|
|
403
405
|
);
|
|
404
406
|
expect(clearEntrySpy).toHaveBeenCalledWith('GET:/accounts/acc1/members');
|
|
405
407
|
expect(clearEntrySpy).toHaveBeenCalledWith('GET:/accounts/acc1');
|
|
408
|
+
expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/accounts/');
|
|
406
409
|
});
|
|
407
410
|
});
|
|
408
411
|
|
|
@@ -421,6 +424,7 @@ describe('OxyServices.accounts', () => {
|
|
|
421
424
|
);
|
|
422
425
|
expect(clearEntrySpy).toHaveBeenCalledWith('GET:/accounts/acc1/members');
|
|
423
426
|
expect(clearEntrySpy).toHaveBeenCalledWith('GET:/accounts/acc1');
|
|
427
|
+
expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/accounts/');
|
|
424
428
|
});
|
|
425
429
|
});
|
|
426
430
|
|
|
@@ -439,6 +443,7 @@ describe('OxyServices.accounts', () => {
|
|
|
439
443
|
);
|
|
440
444
|
expect(clearEntrySpy).toHaveBeenCalledWith('GET:/accounts/acc1/members');
|
|
441
445
|
expect(clearEntrySpy).toHaveBeenCalledWith('GET:/accounts/acc1');
|
|
446
|
+
expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/accounts/');
|
|
442
447
|
expect(clearEntrySpy).toHaveBeenCalledWith('GET:/accounts');
|
|
443
448
|
expect(clearPrefixSpy).toHaveBeenCalledWith('GET:/accounts?');
|
|
444
449
|
});
|