@oxyhq/core 19.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.
Files changed (46) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/i18n/locales/en-US.json +1 -0
  3. package/dist/cjs/i18n/locales/es-ES.json +1 -0
  4. package/dist/cjs/i18n/locales/locales/en-US.json +1 -0
  5. package/dist/cjs/i18n/locales/locales/es-ES.json +1 -0
  6. package/dist/cjs/index.js +9 -2
  7. package/dist/cjs/mixins/OxyServices.accounts.js +42 -29
  8. package/dist/cjs/mixins/OxyServices.followGraph.js +204 -0
  9. package/dist/cjs/mixins/OxyServices.user.js +7 -4
  10. package/dist/cjs/mixins/index.js +4 -0
  11. package/dist/cjs/session/accountProjection.js +45 -9
  12. package/dist/cjs/utils/accountCacheSweep.js +80 -0
  13. package/dist/esm/.tsbuildinfo +1 -1
  14. package/dist/esm/i18n/locales/en-US.json +1 -0
  15. package/dist/esm/i18n/locales/es-ES.json +1 -0
  16. package/dist/esm/i18n/locales/locales/en-US.json +1 -0
  17. package/dist/esm/i18n/locales/locales/es-ES.json +1 -0
  18. package/dist/esm/index.js +7 -1
  19. package/dist/esm/mixins/OxyServices.accounts.js +42 -29
  20. package/dist/esm/mixins/OxyServices.followGraph.js +201 -0
  21. package/dist/esm/mixins/OxyServices.user.js +7 -4
  22. package/dist/esm/mixins/index.js +4 -0
  23. package/dist/esm/session/accountProjection.js +44 -9
  24. package/dist/esm/utils/accountCacheSweep.js +75 -0
  25. package/dist/types/.tsbuildinfo +1 -1
  26. package/dist/types/index.d.ts +1 -1
  27. package/dist/types/mixins/OxyServices.accounts.d.ts +52 -27
  28. package/dist/types/mixins/OxyServices.followGraph.d.ts +211 -0
  29. package/dist/types/mixins/OxyServices.user.d.ts +5 -4
  30. package/dist/types/mixins/index.d.ts +2 -1
  31. package/dist/types/session/accountProjection.d.ts +38 -4
  32. package/dist/types/utils/accountCacheSweep.d.ts +75 -0
  33. package/package.json +2 -2
  34. package/src/i18n/locales/en-US.json +1 -0
  35. package/src/i18n/locales/es-ES.json +1 -0
  36. package/src/index.ts +8 -1
  37. package/src/mixins/OxyServices.accounts.ts +65 -38
  38. package/src/mixins/OxyServices.followGraph.ts +266 -0
  39. package/src/mixins/OxyServices.user.ts +7 -4
  40. package/src/mixins/__tests__/accounts.test.ts +5 -0
  41. package/src/mixins/__tests__/followGraph.test.ts +128 -0
  42. package/src/mixins/__tests__/identityWriteCacheInvalidation.test.ts +37 -0
  43. package/src/mixins/index.ts +5 -0
  44. package/src/session/__tests__/accountProjection.test.ts +109 -2
  45. package/src/session/accountProjection.ts +47 -9
  46. package/src/utils/accountCacheSweep.ts +93 -0
@@ -367,4 +367,41 @@ describe('updateProfile identity-cache invalidation (real cache)', () => {
367
367
  expect(fetchMock).toHaveBeenCalledTimes(++calls);
368
368
  }
369
369
  });
370
+
371
+ it('evicts the account forest list and detail after a self-profile edit', async () => {
372
+ const selfNode: AccountNode = {
373
+ accountId: SELF_ID,
374
+ kind: 'personal',
375
+ parentAccountId: null,
376
+ account: {
377
+ id: SELF_ID,
378
+ publicKey: 'pk-me',
379
+ username: 'alice',
380
+ name: { displayName: 'Alice' },
381
+ avatar: 'old',
382
+ },
383
+ relationship: 'self',
384
+ callerMembership: null,
385
+ };
386
+
387
+ fetchMock.mockResolvedValueOnce(jsonResponse([selfNode]));
388
+ await oxy.listAccounts();
389
+ fetchMock.mockResolvedValueOnce(jsonResponse({ account: selfNode }));
390
+ await oxy.getAccount(SELF_ID);
391
+ expect(fetchMock).toHaveBeenCalledTimes(2);
392
+
393
+ fetchMock.mockResolvedValueOnce(jsonResponse({ id: SELF_ID, avatar: 'new' }));
394
+ await oxy.updateProfile({ avatar: 'new' });
395
+ expect(fetchMock).toHaveBeenCalledTimes(3);
396
+
397
+ fetchMock.mockResolvedValueOnce(jsonResponse([{ ...selfNode, account: { ...selfNode.account!, avatar: 'new' } }]));
398
+ await oxy.listAccounts();
399
+ expect(fetchMock).toHaveBeenCalledTimes(4);
400
+
401
+ fetchMock.mockResolvedValueOnce(
402
+ jsonResponse({ account: { ...selfNode, account: { ...selfNode.account!, avatar: 'new' } } }),
403
+ );
404
+ await oxy.getAccount(SELF_ID);
405
+ expect(fetchMock).toHaveBeenCalledTimes(5);
406
+ });
370
407
  });
@@ -30,6 +30,7 @@ import { OxyServicesAppDataMixin } from './OxyServices.appData';
30
30
  import { OxyServicesCivicMixin } from './OxyServices.civic';
31
31
  import { OxyServicesNodesMixin } from './OxyServices.nodes';
32
32
  import { OxyServicesLinksMixin } from './OxyServices.links';
33
+ import { OxyServicesFollowGraphMixin } from './OxyServices.followGraph';
33
34
  import { OxyServicesDeviceBootMixin } from './OxyServices.deviceBoot';
34
35
  import { OxyServicesDeviceTransferMixin } from './OxyServices.deviceTransfer';
35
36
 
@@ -66,6 +67,7 @@ type AllMixinInstances =
66
67
  & InstanceType<ReturnType<typeof OxyServicesCivicMixin<typeof OxyServicesBase>>>
67
68
  & InstanceType<ReturnType<typeof OxyServicesNodesMixin<typeof OxyServicesBase>>>
68
69
  & InstanceType<ReturnType<typeof OxyServicesLinksMixin<typeof OxyServicesBase>>>
70
+ & InstanceType<ReturnType<typeof OxyServicesFollowGraphMixin<typeof OxyServicesBase>>>
69
71
  & InstanceType<ReturnType<typeof OxyServicesDeviceBootMixin<typeof OxyServicesBase>>>
70
72
  & InstanceType<ReturnType<typeof OxyServicesDeviceTransferMixin<typeof OxyServicesBase>>>
71
73
  & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
@@ -141,6 +143,9 @@ const MIXIN_PIPELINE: MixinFunction[] = [
141
143
  // Link previews / unfurls: SDK-owned link-metadata resolution via oxy-api,
142
144
  // so apps stop scraping link metadata locally.
143
145
  OxyServicesLinksMixin,
146
+ // The user-owned follow graph (#809). One relationship per user and target,
147
+ // shared across applications, with per-application context on top.
148
+ OxyServicesFollowGraphMixin,
144
149
 
145
150
  // Device-first token mint: the client half of the zero-cookie transport
146
151
  // (`mintFromDeviceSecret` → `POST /session/device/token`).
@@ -1,7 +1,9 @@
1
1
  import type { DeviceSessionState } from '@oxyhq/contracts';
2
+ import { ACCOUNT_KINDS } from '@oxyhq/contracts';
2
3
  import type { User } from '../../models/interfaces';
3
4
  import type { AccountNode } from '../../mixins/OxyServices.accounts';
4
5
  import {
6
+ isSwitchTargetAccount,
5
7
  projectSwitchableAccounts,
6
8
  switchableAccountIds,
7
9
  } from '../accountProjection';
@@ -49,6 +51,57 @@ const mapOf = (...users: User[]): Map<string, User> => {
49
51
 
50
52
  const noAvatar = (): undefined => undefined;
51
53
 
54
+ describe('isSwitchTargetAccount', () => {
55
+ /**
56
+ * EXHAUSTIVE over `ACCOUNT_KINDS`, as one object equality rather than a
57
+ * per-kind assertion, for two reasons that a `channel → false` spot-check
58
+ * cannot give:
59
+ *
60
+ * - It distinguishes "excludes channels" from "excludes everything". Three
61
+ * kinds must come back `true` here, so a predicate that answered `false`
62
+ * unconditionally — the shape that empties a switcher instead of filtering
63
+ * it — fails on those three, not on the channel.
64
+ * - Adding a sixth kind fails this test with a missing key, forcing the
65
+ * decision to be made HERE rather than inherited silently from whichever
66
+ * literal comparison happened to be written first.
67
+ */
68
+ it('answers by kind for an account the caller merely owns or is a member of', () => {
69
+ expect(
70
+ Object.fromEntries(
71
+ ACCOUNT_KINDS.map((kind) => [kind, isSwitchTargetAccount({ kind, relationship: 'owner' })]),
72
+ ),
73
+ ).toEqual({
74
+ personal: false,
75
+ organization: true,
76
+ project: true,
77
+ bot: true,
78
+ channel: false,
79
+ });
80
+ });
81
+
82
+ /**
83
+ * The `self` ground, and the reason this predicate is not `isActAsEligibleKind`.
84
+ *
85
+ * `personal` is act-as INELIGIBLE — assuming somebody's human login would be
86
+ * impersonation — so a switcher gated on that predicate alone would drop the
87
+ * caller's OWN account and render an empty list. `GET /accounts` resolves its
88
+ * caller through `resolveOperatorId`, so a `self` node is always the human
89
+ * operator's own personal account, even while they operate an org.
90
+ */
91
+ it('admits the caller’s own personal account, which is act-as ineligible', () => {
92
+ expect(isSwitchTargetAccount({ kind: 'personal', relationship: 'self' })).toBe(true);
93
+ // Same kind, not the caller's own → refused. The `relationship` is doing the
94
+ // work, so neither half of the predicate can be deleted without a failure.
95
+ expect(isSwitchTargetAccount({ kind: 'personal', relationship: 'member' })).toBe(false);
96
+ });
97
+
98
+ it('refuses an account with no kind information rather than assuming', () => {
99
+ expect(isSwitchTargetAccount({})).toBe(false);
100
+ expect(isSwitchTargetAccount({ kind: null })).toBe(false);
101
+ expect(isSwitchTargetAccount({ kind: undefined, relationship: 'owner' })).toBe(false);
102
+ });
103
+ });
104
+
52
105
  describe('projectSwitchableAccounts', () => {
53
106
  it('returns [] for null state and empty graph', () => {
54
107
  expect(
@@ -138,6 +191,50 @@ describe('projectSwitchableAccounts', () => {
138
191
  expect(rows.some((r) => r.kind === 'channel')).toBe(false);
139
192
  });
140
193
 
194
+ /**
195
+ * The same rule as the `isSwitchTargetAccount` matrix above, asserted through
196
+ * the projection so the WIRING is covered and not just the predicate.
197
+ *
198
+ * The fixture deliberately carries one graph-only node of every kind, and
199
+ * FOUR of the five must survive: a fixture list of channels alone could not
200
+ * tell "omits channels" from "omits every graph-only row", which is the
201
+ * failure mode that would silently empty an operator's switcher of the orgs
202
+ * they actually work in. `a1` is a device row and is asserted separately, so
203
+ * the graph lane's output is never confused with the device lane's.
204
+ */
205
+ it('keeps every switchable kind while dropping the channel (graph lane)', () => {
206
+ const rows = projectSwitchableAccounts({
207
+ state: state([{ accountId: 'a1', sessionId: 's1' }], 'a1'),
208
+ graph: [
209
+ graphNode('self1', { kind: 'personal', relationship: 'self' }),
210
+ graphNode('org1', { kind: 'organization' }),
211
+ graphNode('proj1', { kind: 'project' }),
212
+ graphNode('bot1', { kind: 'bot' }),
213
+ graphNode('chan1', { kind: 'channel' }),
214
+ ],
215
+ profilesById: mapOf(
216
+ user('a1'),
217
+ user('self1'),
218
+ user('org1'),
219
+ user('proj1'),
220
+ user('bot1'),
221
+ user('chan1'),
222
+ ),
223
+ resolveAvatarUrl: noAvatar,
224
+ });
225
+
226
+ expect(rows.map((r) => r.accountId)).toEqual(['a1', 'self1', 'org1', 'proj1', 'bot1']);
227
+ // Stated the other way round too, so a fixture that stopped reaching the
228
+ // graph lane at all could not pass this as a vacuous "no channels found".
229
+ expect(rows.map((r) => r.kind)).toEqual([
230
+ undefined,
231
+ 'personal',
232
+ 'organization',
233
+ 'project',
234
+ 'bot',
235
+ ]);
236
+ });
237
+
141
238
  it('dedups an account present as BOTH device session and graph node into ONE enriched row', () => {
142
239
  const rows = projectSwitchableAccounts({
143
240
  state: state([{ accountId: 'a1', sessionId: 's1', authuser: 0 }], 'a1'),
@@ -232,11 +329,21 @@ describe('switchableAccountIds', () => {
232
329
  expect(switchableAccountIds(null, [])).toEqual([]);
233
330
  });
234
331
 
235
- it('omits a graph-only channel, so no profile is fetched for a dropped row', () => {
332
+ /**
333
+ * Must stay in lockstep with the projection's own filter in BOTH directions:
334
+ * an id fetched for a dropped row is wasted work, but an id NOT fetched for a
335
+ * row the projection keeps is worse — that row has no profile, so the
336
+ * projection's device lane skips it and it silently never renders.
337
+ */
338
+ it('applies the same switch-target filter as the projection', () => {
236
339
  const ids = switchableAccountIds(null, [
340
+ graphNode('self1', { kind: 'personal', relationship: 'self' }),
237
341
  graphNode('org1', { kind: 'organization' }),
342
+ graphNode('proj1', { kind: 'project' }),
343
+ graphNode('bot1', { kind: 'bot' }),
238
344
  graphNode('chan1', { kind: 'channel' }),
345
+ graphNode('other1', { kind: 'personal', relationship: 'member' }),
239
346
  ]);
240
- expect(ids).toEqual(['org1']);
347
+ expect(ids).toEqual(['bot1', 'org1', 'proj1', 'self1']);
241
348
  });
242
349
  });
@@ -103,6 +103,38 @@ export interface SwitchableAccount {
103
103
  user: SwitchableAccountUser;
104
104
  }
105
105
 
106
+ /**
107
+ * Whether the caller can BECOME this account — the one question every account
108
+ * switcher asks, answered here so no surface has to re-derive it.
109
+ *
110
+ * Two independent grounds, either of which suffices:
111
+ *
112
+ * - **It is already the caller's own identity** (`relationship: 'self'`).
113
+ * `GET /accounts` resolves its caller through `resolveOperatorId`, so `self`
114
+ * is the HUMAN operator's personal account even while they are operating an
115
+ * org — never the operated account. Kind is irrelevant on this ground: the
116
+ * caller IS that account, so returning to it asks the server for nothing.
117
+ * - **The server will mint a session for it** — `isActAsEligibleKind(kind)` is
118
+ * the exact predicate `POST /accounts/:id/switch` enforces, so a row offered
119
+ * on this ground is never a dead button.
120
+ *
121
+ * `isActAsEligibleKind` ALONE is not this question, and reaching for it
122
+ * directly is the mistake this function exists to prevent: it is false for
123
+ * `personal` as well as `channel`, so a switcher gated on it alone renders an
124
+ * empty list rather than a filtered one. Equally, `kind !== 'channel'` is not
125
+ * this question either — it silently admits every kind invented after it was
126
+ * written, which is the same trap `isActAsEligibleKind` was introduced to close
127
+ * on the server.
128
+ *
129
+ * Takes a structural subset rather than a whole {@link AccountNode} so a caller
130
+ * holding a projected {@link SwitchableAccount} can ask it too.
131
+ */
132
+ export function isSwitchTargetAccount(
133
+ node: { kind?: AccountKind | null; relationship?: AccountRelationship },
134
+ ): boolean {
135
+ return node.relationship === 'self' || isActAsEligibleKind(node.kind);
136
+ }
137
+
106
138
  /** Input to {@link projectSwitchableAccounts}. */
107
139
  export interface ProjectSwitchableAccountsInput {
108
140
  /**
@@ -144,8 +176,9 @@ export interface ProjectSwitchableAccountsInput {
144
176
  * and a graph node is deduped into ONE device row enriched with the graph
145
177
  * metadata (relationship / kind / parent / membership).
146
178
  *
147
- * Graph nodes of a kind nobody may act as (`channel`) are omitted see the
148
- * filter below.
179
+ * Graph nodes that are not switch targets a `channel`, which nobody may act
180
+ * as — are omitted. {@link isSwitchTargetAccount} is the rule; see the filter
181
+ * below.
149
182
  */
150
183
  export function projectSwitchableAccounts(input: ProjectSwitchableAccountsInput): SwitchableAccount[] {
151
184
  const { state, graph, profilesById, activeUser, locale, resolveAvatarUrl } = input;
@@ -236,10 +269,12 @@ export function projectSwitchableAccounts(input: ProjectSwitchableAccountsInput)
236
269
  // construction": the graph contributes accounts that have no device session
237
270
  // and no credentials at all, which is exactly how an org first becomes
238
271
  // switchable. So a kind that must never be switched into has to be filtered
239
- // HERE, and `isActAsEligibleKind` is the same predicate the server enforces
240
- // on `POST /accounts/:id/switch` — offering a row the server would 403 is a
241
- // dead button.
242
- if (!isActAsEligibleKind(node.kind)) {
272
+ // HERE offering a row the server would 403 is a dead button.
273
+ //
274
+ // An account already on the device skipped this check via the branch above,
275
+ // and correctly: whatever its kind, the caller is signed into it, so
276
+ // switching is a local activation that asks the server for nothing.
277
+ if (!isSwitchTargetAccount(node)) {
243
278
  continue;
244
279
  }
245
280
  remember(toRow(node.account, {
@@ -263,8 +298,11 @@ export function projectSwitchableAccounts(input: ProjectSwitchableAccountsInput)
263
298
  * document, but including their ids lets the caller pass one id set and lets the
264
299
  * projection prefer freshly-fetched profiles uniformly.
265
300
  *
266
- * Applies the SAME act-as filter as {@link projectSwitchableAccounts} to graph
267
- * nodes, so this never fetches a profile for a row the projection will drop.
301
+ * Applies the SAME {@link isSwitchTargetAccount} filter as
302
+ * {@link projectSwitchableAccounts} to graph nodes, so this never fetches a
303
+ * profile for a row the projection will drop — and, just as importantly, never
304
+ * SKIPS one the projection will keep, which would leave that row unrendered
305
+ * until some later fetch happened to resolve it.
268
306
  */
269
307
  export function switchableAccountIds(
270
308
  state: DeviceSessionState | null,
@@ -277,7 +315,7 @@ export function switchableAccountIds(
277
315
  }
278
316
  }
279
317
  for (const node of graph) {
280
- if (node.accountId && isActAsEligibleKind(node.kind)) {
318
+ if (node.accountId && isSwitchTargetAccount(node)) {
281
319
  ids.add(node.accountId);
282
320
  }
283
321
  }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * THE enumeration of `OxyServices` GET-cache keys that serve the ACCOUNT FOREST
3
+ * — the caller's accessible accounts, as lists and as individual detail rows —
4
+ * and the one sweep that clears them.
5
+ *
6
+ * WHY THIS IS NOT A METHOD ON THE ACCOUNTS MIXIN
7
+ * ---------------------------------------------
8
+ * `AccountNode.account` is a whole `User`, so a forest read embeds the very
9
+ * profile the identity reads serve. That makes an IDENTITY write a writer of
10
+ * these keys too: `updateProfile` edits the caller's own personal account,
11
+ * which is a row in `GET /accounts` and is its own `GET /accounts/<id>`. Leave
12
+ * those cached and the account switcher keeps drawing the pre-edit name and
13
+ * picture for the full TTL, against a perfectly healthy server.
14
+ *
15
+ * The mixins compose into one class at runtime but are typed one at a time, so
16
+ * the user mixin cannot call a method the accounts mixin owns. The key list
17
+ * therefore lives here, once, and every writer calls {@link
18
+ * evictOxyAccountForestCache} — exactly like the identity key list in
19
+ * `identityCacheSweep`, which the accounts mixin already calls for the
20
+ * mirror-image case (an account write staling the identity reads). The
21
+ * alternative — a second hand-written copy of these keys in the other mixin —
22
+ * is the drift that shipped the two stale-profile bugs `identityCacheSweep`
23
+ * documents.
24
+ *
25
+ * WHY THE LIST NEEDS A PREFIX AND THE DETAIL DOES NOT
26
+ * --------------------------------------------------
27
+ * `listAccounts({tree?})` keys the flat list as `GET:/accounts` and every
28
+ * option variant as `GET:/accounts?<query>` (the query string is part of the
29
+ * URL, hence of the key), and a writer cannot enumerate which variants a caller
30
+ * has read. The detail key, by contrast, is derivable from the account id the
31
+ * writer already holds.
32
+ *
33
+ * The `GET:/accounts?` prefix matches ONLY the query-string list variants —
34
+ * never `GET:/accounts/<id>` or its `…/members`, `…/credentials`, `…/children`
35
+ * sub-resources, which are the accounts mixin's own business and stay there.
36
+ */
37
+
38
+ import type { OxyIdentityCacheEvictor } from './identityCacheSweep';
39
+
40
+ /**
41
+ * The cache-eviction surface of an `OxyServices` instance. Reused from
42
+ * `identityCacheSweep` rather than re-declared: it is the SDK's one published
43
+ * name for these two methods, and a second identical interface would be one
44
+ * more shape to keep in step.
45
+ */
46
+ export type OxyAccountCacheEvictor = OxyIdentityCacheEvictor;
47
+
48
+ /** The cache key `listAccounts()` reads under with no options. */
49
+ export const OXY_ACCOUNT_LIST_CACHE_KEY = 'GET:/accounts';
50
+
51
+ /**
52
+ * The prefix covering every option-carrying `listAccounts(opts)` variant
53
+ * (`?tree=true`, …), none of which a writer can enumerate.
54
+ */
55
+ export const OXY_ACCOUNT_LIST_CACHE_QUERY_PREFIX = 'GET:/accounts?';
56
+
57
+ /**
58
+ * Prefix covering every per-account sub-resource cache key
59
+ * (`GET:/accounts/<id>`, `…/members`, `…/credentials`, `…/children`). A
60
+ * membership mutation on an ancestor must sweep ALL of these, not only the
61
+ * account named in the path: descendant member rosters embed inherited rows
62
+ * resolved from that ancestor, and the writer cannot enumerate which descendant
63
+ * ids a caller has already read. The trailing slash deliberately excludes the
64
+ * forest list keys (`GET:/accounts`, `GET:/accounts?…`) documented above.
65
+ */
66
+ export const OXY_ACCOUNT_PER_ACCOUNT_CACHE_PREFIX = 'GET:/accounts/';
67
+
68
+ /**
69
+ * Build the exact cache key `getAccount(accountId)` reads under.
70
+ */
71
+ export function oxyAccountDetailCacheKey(accountId: string): string {
72
+ return `GET:/accounts/${encodeURIComponent(accountId)}`;
73
+ }
74
+
75
+ /**
76
+ * Sweep an `OxyServices` GET response cache of the account forest.
77
+ *
78
+ * @param oxy - Anything exposing the SDK's two eviction methods.
79
+ * @param accountId - The account whose detail row to drop as well. Optional: a
80
+ * writer that changed the SHAPE of the forest rather than one
81
+ * account in it (create, archive, ownership transfer) has no
82
+ * detail row to name, and clears only the lists.
83
+ */
84
+ export function evictOxyAccountForestCache(
85
+ oxy: OxyAccountCacheEvictor,
86
+ accountId?: string,
87
+ ): void {
88
+ oxy.clearCacheEntry(OXY_ACCOUNT_LIST_CACHE_KEY);
89
+ oxy.clearCacheByPrefix(OXY_ACCOUNT_LIST_CACHE_QUERY_PREFIX);
90
+ if (accountId) {
91
+ oxy.clearCacheEntry(oxyAccountDetailCacheKey(accountId));
92
+ }
93
+ }