@oxy.so/services 8.1.1 → 8.3.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 (112) hide show
  1. package/lib/commonjs/index.js +2 -0
  2. package/lib/commonjs/index.js.map +1 -1
  3. package/lib/commonjs/ui/components/OxyProvider.js +5 -1
  4. package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
  5. package/lib/commonjs/ui/components/RequireOxyAuth.js +9 -1
  6. package/lib/commonjs/ui/components/RequireOxyAuth.js.map +1 -1
  7. package/lib/commonjs/ui/components/signIn/OxyAuthenticatorPanel.js +3 -7
  8. package/lib/commonjs/ui/components/signIn/OxyAuthenticatorPanel.js.map +1 -1
  9. package/lib/commonjs/ui/components/signIn/OxySignInPanel.js +34 -27
  10. package/lib/commonjs/ui/components/signIn/OxySignInPanel.js.map +1 -1
  11. package/lib/commonjs/ui/components/signIn/ReauthStep.js +27 -19
  12. package/lib/commonjs/ui/components/signIn/ReauthStep.js.map +1 -1
  13. package/lib/commonjs/ui/components/signIn/accountFlowParts.js +54 -22
  14. package/lib/commonjs/ui/components/signIn/accountFlowParts.js.map +1 -1
  15. package/lib/commonjs/ui/context/OxyContext.js +25 -4
  16. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  17. package/lib/commonjs/ui/hooks/accountQueryPersistence.js +157 -0
  18. package/lib/commonjs/ui/hooks/accountQueryPersistence.js.map +1 -0
  19. package/lib/commonjs/ui/hooks/queryClient.js +17 -9
  20. package/lib/commonjs/ui/hooks/queryClient.js.map +1 -1
  21. package/lib/commonjs/ui/index.js.map +1 -1
  22. package/lib/commonjs/ui/utils/buildId.js +25 -0
  23. package/lib/commonjs/ui/utils/buildId.js.map +1 -0
  24. package/lib/module/index.js +3 -0
  25. package/lib/module/index.js.map +1 -1
  26. package/lib/module/ui/components/OxyProvider.js +5 -1
  27. package/lib/module/ui/components/OxyProvider.js.map +1 -1
  28. package/lib/module/ui/components/RequireOxyAuth.js +9 -1
  29. package/lib/module/ui/components/RequireOxyAuth.js.map +1 -1
  30. package/lib/module/ui/components/signIn/OxyAuthenticatorPanel.js +4 -8
  31. package/lib/module/ui/components/signIn/OxyAuthenticatorPanel.js.map +1 -1
  32. package/lib/module/ui/components/signIn/OxySignInPanel.js +36 -29
  33. package/lib/module/ui/components/signIn/OxySignInPanel.js.map +1 -1
  34. package/lib/module/ui/components/signIn/ReauthStep.js +29 -21
  35. package/lib/module/ui/components/signIn/ReauthStep.js.map +1 -1
  36. package/lib/module/ui/components/signIn/accountFlowParts.js +51 -19
  37. package/lib/module/ui/components/signIn/accountFlowParts.js.map +1 -1
  38. package/lib/module/ui/context/OxyContext.js +27 -6
  39. package/lib/module/ui/context/OxyContext.js.map +1 -1
  40. package/lib/module/ui/hooks/accountQueryPersistence.js +153 -0
  41. package/lib/module/ui/hooks/accountQueryPersistence.js.map +1 -0
  42. package/lib/module/ui/hooks/queryClient.js +18 -10
  43. package/lib/module/ui/hooks/queryClient.js.map +1 -1
  44. package/lib/module/ui/index.js +3 -0
  45. package/lib/module/ui/index.js.map +1 -1
  46. package/lib/module/ui/utils/buildId.js +21 -0
  47. package/lib/module/ui/utils/buildId.js.map +1 -0
  48. package/lib/typescript/commonjs/index.d.ts +1 -0
  49. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  50. package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/ui/components/RequireOxyAuth.d.ts.map +1 -1
  52. package/lib/typescript/commonjs/ui/components/signIn/OxyAuthenticatorPanel.d.ts.map +1 -1
  53. package/lib/typescript/commonjs/ui/components/signIn/OxySignInPanel.d.ts +6 -3
  54. package/lib/typescript/commonjs/ui/components/signIn/OxySignInPanel.d.ts.map +1 -1
  55. package/lib/typescript/commonjs/ui/components/signIn/ReauthStep.d.ts.map +1 -1
  56. package/lib/typescript/commonjs/ui/components/signIn/accountFlowParts.d.ts +29 -9
  57. package/lib/typescript/commonjs/ui/components/signIn/accountFlowParts.d.ts.map +1 -1
  58. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  59. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts +9 -0
  60. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts.map +1 -1
  61. package/lib/typescript/commonjs/ui/hooks/accountQueryPersistence.d.ts +54 -0
  62. package/lib/typescript/commonjs/ui/hooks/accountQueryPersistence.d.ts.map +1 -0
  63. package/lib/typescript/commonjs/ui/hooks/queryClient.d.ts +8 -2
  64. package/lib/typescript/commonjs/ui/hooks/queryClient.d.ts.map +1 -1
  65. package/lib/typescript/commonjs/ui/icons/Ionicons.d.ts +1 -1
  66. package/lib/typescript/commonjs/ui/icons/MaterialCommunityIcons.d.ts +1 -1
  67. package/lib/typescript/commonjs/ui/index.d.ts +1 -0
  68. package/lib/typescript/commonjs/ui/index.d.ts.map +1 -1
  69. package/lib/typescript/commonjs/ui/types/navigation.d.ts +9 -0
  70. package/lib/typescript/commonjs/ui/types/navigation.d.ts.map +1 -1
  71. package/lib/typescript/commonjs/ui/utils/buildId.d.ts +16 -0
  72. package/lib/typescript/commonjs/ui/utils/buildId.d.ts.map +1 -0
  73. package/lib/typescript/module/index.d.ts +1 -0
  74. package/lib/typescript/module/index.d.ts.map +1 -1
  75. package/lib/typescript/module/ui/components/OxyProvider.d.ts.map +1 -1
  76. package/lib/typescript/module/ui/components/RequireOxyAuth.d.ts.map +1 -1
  77. package/lib/typescript/module/ui/components/signIn/OxyAuthenticatorPanel.d.ts.map +1 -1
  78. package/lib/typescript/module/ui/components/signIn/OxySignInPanel.d.ts +6 -3
  79. package/lib/typescript/module/ui/components/signIn/OxySignInPanel.d.ts.map +1 -1
  80. package/lib/typescript/module/ui/components/signIn/ReauthStep.d.ts.map +1 -1
  81. package/lib/typescript/module/ui/components/signIn/accountFlowParts.d.ts +29 -9
  82. package/lib/typescript/module/ui/components/signIn/accountFlowParts.d.ts.map +1 -1
  83. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  84. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts +9 -0
  85. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts.map +1 -1
  86. package/lib/typescript/module/ui/hooks/accountQueryPersistence.d.ts +54 -0
  87. package/lib/typescript/module/ui/hooks/accountQueryPersistence.d.ts.map +1 -0
  88. package/lib/typescript/module/ui/hooks/queryClient.d.ts +8 -2
  89. package/lib/typescript/module/ui/hooks/queryClient.d.ts.map +1 -1
  90. package/lib/typescript/module/ui/icons/Ionicons.d.ts +1 -1
  91. package/lib/typescript/module/ui/icons/MaterialCommunityIcons.d.ts +1 -1
  92. package/lib/typescript/module/ui/index.d.ts +1 -0
  93. package/lib/typescript/module/ui/index.d.ts.map +1 -1
  94. package/lib/typescript/module/ui/types/navigation.d.ts +9 -0
  95. package/lib/typescript/module/ui/types/navigation.d.ts.map +1 -1
  96. package/lib/typescript/module/ui/utils/buildId.d.ts +16 -0
  97. package/lib/typescript/module/ui/utils/buildId.d.ts.map +1 -0
  98. package/package.json +7 -7
  99. package/src/index.ts +2 -0
  100. package/src/ui/components/OxyProvider.tsx +5 -1
  101. package/src/ui/components/RequireOxyAuth.tsx +7 -2
  102. package/src/ui/components/signIn/OxyAuthenticatorPanel.tsx +4 -8
  103. package/src/ui/components/signIn/OxySignInPanel.tsx +41 -27
  104. package/src/ui/components/signIn/ReauthStep.tsx +33 -21
  105. package/src/ui/components/signIn/accountFlowParts.tsx +64 -22
  106. package/src/ui/context/OxyContext.tsx +35 -5
  107. package/src/ui/context/oxyContextTypes.ts +9 -0
  108. package/src/ui/hooks/accountQueryPersistence.ts +201 -0
  109. package/src/ui/hooks/queryClient.ts +17 -8
  110. package/src/ui/index.ts +2 -0
  111. package/src/ui/types/navigation.ts +9 -0
  112. package/src/ui/utils/buildId.ts +18 -0
@@ -7,6 +7,7 @@ import {
7
7
  useMemo,
8
8
  useRef,
9
9
  useState,
10
+ useSyncExternalStore,
10
11
  } from 'react';
11
12
  import { Linking, Platform } from 'react-native';
12
13
  import { OxyServices } from '@oxy.so/core';
@@ -43,7 +44,7 @@ import { useLanguageManagement } from '../hooks/useLanguageManagement';
43
44
  import { useSessionManagement } from '../hooks/useSessionManagement';
44
45
  import { useAuthOperations, clearPersistedAuthSafe } from './hooks/useAuthOperations';
45
46
  import { useDeviceManagement } from '../hooks/useDeviceManagement';
46
- import { getStorageKeys, createPlatformStorage, type StorageInterface } from '../utils/storageHelpers';
47
+ import { getStorageKeys, createMemoryStorage, createPlatformStorage, type StorageInterface } from '../utils/storageHelpers';
47
48
  import type { RouteName } from '../navigation/routes';
48
49
  import { showBottomSheet as globalShowBottomSheet } from '../navigation/bottomSheetManager';
49
50
  import {
@@ -54,6 +55,7 @@ import {
54
55
  } from '../navigation/surfaces';
55
56
  import { useQueryClient, onlineManager } from '@tanstack/react-query';
56
57
  import { clearQueryCache } from '../hooks/queryClient';
58
+ import { createAccountQueryPersistence, type AccountQueryPersistence } from '../hooks/accountQueryPersistence';
57
59
  import { useAvatarPicker } from '../hooks/useAvatarPicker';
58
60
  import { resetSessionScopedStores } from '../stores/resetSessionScopedStores';
59
61
  import { ASSET_DOWNLOAD_URLS_QUERY_KEY } from '../hooks/useResolvedFileUrls';
@@ -94,6 +96,8 @@ export type { LogoutResult, OxyContextState, OxyRuntimeProviderProps } from './o
94
96
 
95
97
  const OxyRuntimeContext = createContext<OxyContextState | null>(null);
96
98
 
99
+ const subscribeNever = (): (() => void) => () => undefined;
100
+
97
101
  type OxyAuthActionsContextState = Pick<
98
102
  OxyContextState,
99
103
  | 'signIn'
@@ -135,6 +139,7 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
135
139
  webAuthMode = 'popup',
136
140
  backgroundSession = false,
137
141
  platformStorage,
142
+ accountQueries,
138
143
  onAuthStateChange,
139
144
  onError,
140
145
  }) => {
@@ -283,6 +288,24 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
283
288
  // Local display/bookkeeping storage (session id list, language). Distinct
284
289
  // from `authStore` (the durable credential blob). Exposed as an awaitable so
285
290
  // a persistence write is never dropped because it raced storage init.
291
+ const queryClient = useQueryClient();
292
+
293
+ // The app's per-account offline cache (`accountQueries`). Built once, like the
294
+ // runtime. `handleSubjectChange` activates it BEFORE anyone is woken, so a
295
+ // consumer of the new account never reads the previous account's rows.
296
+ const accountQueryPersistenceRef = useRef<AccountQueryPersistence | null | undefined>(undefined);
297
+ if (accountQueryPersistenceRef.current === undefined) {
298
+ accountQueryPersistenceRef.current = accountQueries
299
+ ? createAccountQueryPersistence(queryClient, accountQueries)
300
+ : null;
301
+ }
302
+ const accountQueryPersistence = accountQueryPersistenceRef.current;
303
+ const isAccountCacheReady = useSyncExternalStore(
304
+ accountQueryPersistence ? accountQueryPersistence.subscribe : subscribeNever,
305
+ () => accountQueryPersistence?.isReady() ?? true,
306
+ () => accountQueryPersistence?.isReady() ?? true,
307
+ );
308
+
286
309
  const storageRef = useRef<StorageInterface | null>(null);
287
310
  const [storage, setStorage] = useState<StorageInterface | null>(null);
288
311
  const buildStorageDeferred = () => {
@@ -313,12 +336,16 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
313
336
  .then((storageInstance) => {
314
337
  storageRef.current = storageInstance;
315
338
  storageReady.resolve(storageInstance);
339
+ accountQueryPersistence?.setStorage(storageInstance);
316
340
  if (mounted) {
317
341
  setStorage(storageInstance);
318
342
  runtime.setStorageReady(true);
319
343
  }
320
344
  })
321
345
  .catch((err) => {
346
+ // Nothing persisted can be restored; the account cache still has to
347
+ // become ready, or RequireOxyAuth would wait forever.
348
+ accountQueryPersistence?.setStorage(createMemoryStorage());
322
349
  if (mounted) {
323
350
  logger('Failed to initialize storage', err);
324
351
  onError?.({ message: 'Failed to initialize storage', code: 'STORAGE_INIT_ERROR', status: 500 });
@@ -327,7 +354,7 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
327
354
  return () => {
328
355
  mounted = false;
329
356
  };
330
- }, [logger, onError, platformStorage, runtime, storageReady]);
357
+ }, [accountQueryPersistence, logger, onError, platformStorage, runtime, storageReady]);
331
358
 
332
359
  const {
333
360
  currentLanguage,
@@ -344,8 +371,6 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
344
371
  logger,
345
372
  });
346
373
 
347
- const queryClient = useQueryClient();
348
-
349
374
  const {
350
375
  saveSessionIds,
351
376
  switchSession,
@@ -1207,6 +1232,9 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
1207
1232
  // buy a refetch storm at first paint.
1208
1233
  const handleSubjectChange = useCallback(
1209
1234
  ({ previous, next }: SubjectTransition): void => {
1235
+ // Every transition, the first sign-in included: the account cache is
1236
+ // keyed by the subject, and a sign-out deletes the signed-out one's.
1237
+ accountQueryPersistence?.activate(next);
1210
1238
  if (previous === null) {
1211
1239
  return;
1212
1240
  }
@@ -1224,7 +1252,7 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
1224
1252
  queryClient.invalidateQueries();
1225
1253
  void refreshAccounts();
1226
1254
  },
1227
- [queryClient, refreshAccounts],
1255
+ [accountQueryPersistence, queryClient, refreshAccounts],
1228
1256
  );
1229
1257
  subjectChangeRef.current = handleSubjectChange;
1230
1258
 
@@ -1256,6 +1284,7 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
1256
1284
  isPrivateApiPending,
1257
1285
  isAuthResolved: authResolved,
1258
1286
  isStorageReady: snapshot.storageReady,
1287
+ isAccountCacheReady,
1259
1288
  sessionMode: isIdentityBound ? 'identity' : 'account',
1260
1289
  webAuthMode,
1261
1290
  error,
@@ -1310,6 +1339,7 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
1310
1339
  isPrivateApiPending,
1311
1340
  authResolved,
1312
1341
  snapshot.storageReady,
1342
+ isAccountCacheReady,
1313
1343
  isIdentityBound,
1314
1344
  webAuthMode,
1315
1345
  error,
@@ -12,6 +12,7 @@ import type {
12
12
  } from '../oauth/explicitOAuthConsent';
13
13
  import type { WebAuthMode, WebOAuthSignInResult } from '../oauth/types';
14
14
  import type { StorageInterface } from '../utils/storageHelpers';
15
+ import type { AccountQueriesConfig } from '../hooks/accountQueryPersistence';
15
16
 
16
17
  /**
17
18
  * The outcome of `logout()`. It never rejects: a caller reads this instead.
@@ -48,6 +49,12 @@ export interface OxyContextState {
48
49
  */
49
50
  isAuthResolved: boolean;
50
51
  isStorageReady: boolean;
52
+ /**
53
+ * Whether the signed-in account's persisted `accountQueries` cache has been
54
+ * restored. Always `true` signed out or when the app declared none.
55
+ * `RequireOxyAuth` waits on it.
56
+ */
57
+ isAccountCacheReady: boolean;
51
58
  /**
52
59
  * Who owns this provider's session (see `OxyProviderProps.sessionMode`).
53
60
  *
@@ -200,6 +207,8 @@ export interface OxyRuntimeProviderProps {
200
207
  onError?: (error: ApiError) => void;
201
208
  /** Storage instance owned by the public provider; internal composition seam. */
202
209
  platformStorage?: StorageInterface | null;
210
+ /** See `OxyProviderProps.accountQueries`. Read once, at mount. */
211
+ accountQueries?: AccountQueriesConfig;
203
212
  }
204
213
 
205
214
  /** Internal commit input — session plus zero-cookie device credential. */
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Offline persistence for an app's PRIVATE, per-account queries.
3
+ *
4
+ * The SDK's own persister (`attachQueryPersistence`) keeps account-agnostic
5
+ * data. An app's mail, library or drafts belong to one account, and every app
6
+ * that wanted them to survive a restart had to scope a persister to the session
7
+ * itself. The SDK owns the session, so it owns this too:
8
+ *
9
+ * - Storage is keyed by the account (`oxy_account_queries:<accountId>`), so a
10
+ * restore can only ever hydrate the account that is signed in.
11
+ * - The buster is the build (`getOxyBuildId`), so no cache written by an older
12
+ * bundle, in an older shape, is ever served.
13
+ * - On a subject change the declared roots, memory-only ones included, and the
14
+ * account's paused mutations are dropped
15
+ * from memory synchronously, before anyone is woken, and the new account's
16
+ * blob is restored. `isReady()` is false until that restore settles, and
17
+ * `RequireOxyAuth` waits on it, so another account's rows never render.
18
+ * - On sign-out the signed-out account's blob is deleted.
19
+ */
20
+
21
+ import { hashKey, type Mutation, type Query, type QueryClient, type QueryKey } from '@tanstack/react-query';
22
+ import { persistQueryClient } from '@tanstack/react-query-persist-client';
23
+ import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
24
+ import type { StorageInterface } from '../utils/storageHelpers';
25
+ import { getOxyBuildId } from '../utils/buildId';
26
+
27
+ /** An app's declaration of which data is private to the signed-in account. */
28
+ export interface AccountQueriesConfig {
29
+ /**
30
+ * Query-key roots (`queryKey[0]`) whose data belongs to the signed-in account
31
+ * and survives a restart.
32
+ */
33
+ roots: readonly string[];
34
+ /**
35
+ * Roots that belong to the account too, but must not be written to disk
36
+ * (signed URLs, AI output, search results): dropped on a switch, never persisted.
37
+ */
38
+ memoryOnlyRoots?: readonly string[];
39
+ /**
40
+ * Mutation keys whose PAUSED (offline) instances replay after a restart for
41
+ * the same account. Register their `mutationFn` with `setMutationDefaults`.
42
+ */
43
+ mutationKeys?: readonly QueryKey[];
44
+ }
45
+
46
+ export const ACCOUNT_QUERY_CACHE_KEY = 'oxy_account_queries';
47
+ const ACCOUNT_QUERY_CACHE_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
48
+ const ACCOUNT_QUERY_PERSIST_THROTTLE_MS = 1_000;
49
+
50
+ export interface AccountQueryPersistence {
51
+ /** Storage became available (it initializes asynchronously). */
52
+ setStorage(storage: StorageInterface): void;
53
+ /** The signed-in account changed. `null` is a sign-out. Synchronous in-memory reset. */
54
+ activate(accountId: string | null): void;
55
+ /** True once the active account's cache is restored (always true signed out). */
56
+ isReady(): boolean;
57
+ subscribe(listener: () => void): () => void;
58
+ /** Whether a mutation is account-scoped (so the SDK's own persister skips it). */
59
+ ownsMutation(mutation: Mutation): boolean;
60
+ dispose(): void;
61
+ }
62
+
63
+ function storageKeyFor(accountId: string): string {
64
+ return `${ACCOUNT_QUERY_CACHE_KEY}:${encodeURIComponent(accountId)}`;
65
+ }
66
+
67
+ export function createAccountQueryPersistence(
68
+ queryClient: QueryClient,
69
+ config: AccountQueriesConfig,
70
+ ): AccountQueryPersistence {
71
+ const persistedRoots = new Set(config.roots);
72
+ const accountRoots = new Set([...config.roots, ...(config.memoryOnlyRoots ?? [])]);
73
+ const mutationHashes = new Set((config.mutationKeys ?? []).map((key) => hashKey(key)));
74
+ const listeners = new Set<() => void>();
75
+
76
+ let storage: StorageInterface | null = null;
77
+ let accountId: string | null = null;
78
+ let restoredFor: string | null = null;
79
+ let generation = 0;
80
+ let unsubscribePersist: (() => void) | null = null;
81
+ // The in-flight restore. A new account waits for it, so a late restore can
82
+ // never hydrate the previous account's rows after the switch.
83
+ let restoring: Promise<void> = Promise.resolve();
84
+
85
+ const notify = () => {
86
+ for (const listener of listeners) listener();
87
+ };
88
+
89
+ const rootOf = (query: Query): string | null => {
90
+ const head = query.queryKey[0];
91
+ return typeof head === 'string' ? head : null;
92
+ };
93
+ const ownsQuery = (query: Query): boolean => accountRoots.has(rootOf(query) ?? '');
94
+ const persistsQuery = (query: Query): boolean => persistedRoots.has(rootOf(query) ?? '');
95
+
96
+ const ownsMutation = (mutation: Mutation): boolean => {
97
+ const key = mutation.options.mutationKey;
98
+ return key !== undefined && mutationHashes.has(hashKey(key));
99
+ };
100
+
101
+ const dropScopedData = () => {
102
+ queryClient.removeQueries({ predicate: ownsQuery });
103
+ const mutations = queryClient.getMutationCache();
104
+ for (const mutation of mutations.getAll()) {
105
+ if (ownsMutation(mutation)) mutations.remove(mutation);
106
+ }
107
+ };
108
+
109
+ const persisterFor = (id: string, target: StorageInterface) =>
110
+ createAsyncStoragePersister({
111
+ storage: {
112
+ getItem: (key) => target.getItem(key),
113
+ setItem: (key, value) => target.setItem(key, value),
114
+ removeItem: (key) => target.removeItem(key),
115
+ },
116
+ key: storageKeyFor(id),
117
+ throttleTime: ACCOUNT_QUERY_PERSIST_THROTTLE_MS,
118
+ });
119
+
120
+ const start = () => {
121
+ const id = accountId;
122
+ const target = storage;
123
+ if (!id || !target || unsubscribePersist) return;
124
+ const current = generation;
125
+ const [unsubscribe, restored] = persistQueryClient({
126
+ queryClient,
127
+ persister: persisterFor(id, target),
128
+ maxAge: ACCOUNT_QUERY_CACHE_MAX_AGE,
129
+ buster: getOxyBuildId(),
130
+ dehydrateOptions: {
131
+ shouldDehydrateQuery: (query) => query.state.status === 'success' && persistsQuery(query),
132
+ shouldDehydrateMutation: (mutation) => mutation.state.isPaused && ownsMutation(mutation),
133
+ },
134
+ });
135
+ unsubscribePersist = unsubscribe;
136
+ restoring = restored.catch(() => undefined).then(() => {
137
+ if (current !== generation) return;
138
+ restoredFor = id;
139
+ notify();
140
+ });
141
+ };
142
+
143
+ const stop = () => {
144
+ unsubscribePersist?.();
145
+ unsubscribePersist = null;
146
+ };
147
+
148
+ return {
149
+ setStorage(next) {
150
+ if (storage) return;
151
+ storage = next;
152
+ const current = generation;
153
+ void restoring.then(() => {
154
+ if (current === generation) start();
155
+ });
156
+ },
157
+
158
+ activate(next) {
159
+ if (next === accountId) return;
160
+ const previous = accountId;
161
+ generation += 1;
162
+ const current = generation;
163
+ stop();
164
+ dropScopedData();
165
+ accountId = next;
166
+ restoredFor = null;
167
+ notify();
168
+
169
+ if (next === null && previous !== null && storage) {
170
+ void persisterFor(previous, storage).removeClient();
171
+ }
172
+
173
+ void restoring.then(() => {
174
+ if (current !== generation) return;
175
+ // A restore that was already in flight may have hydrated the previous
176
+ // account's rows; drop them again before this account's restore.
177
+ dropScopedData();
178
+ start();
179
+ });
180
+ },
181
+
182
+ isReady() {
183
+ return accountId === null || restoredFor === accountId;
184
+ },
185
+
186
+ subscribe(listener) {
187
+ listeners.add(listener);
188
+ return () => {
189
+ listeners.delete(listener);
190
+ };
191
+ },
192
+
193
+ ownsMutation,
194
+
195
+ dispose() {
196
+ generation += 1;
197
+ stop();
198
+ listeners.clear();
199
+ },
200
+ };
201
+ }
@@ -24,7 +24,7 @@
24
24
  * when introducing reads that should survive restart.
25
25
  */
26
26
 
27
- import { QueryClient, onlineManager, type Query, type Mutation } from '@tanstack/react-query';
27
+ import { QueryClient, hashKey, onlineManager, type Query, type Mutation, type QueryKey } from '@tanstack/react-query';
28
28
  import {
29
29
  persistQueryClient,
30
30
  type PersistedClient,
@@ -32,6 +32,7 @@ import {
32
32
  import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
33
33
  import { isDev } from '@oxy.so/core';
34
34
  import type { StorageInterface } from '../utils/storageHelpers';
35
+ import { getOxyBuildId } from '../utils/buildId';
35
36
 
36
37
  const QUERY_CACHE_KEY = 'oxy_query_cache_v3';
37
38
  const QUERY_CACHE_MAX_AGE = 30 * 24 * 60 * 60 * 1000; // 30 days
@@ -106,13 +107,6 @@ function shouldDehydrateQuery(query: Query): boolean {
106
107
  return PERSISTED_QUERY_PREFIXES.includes(head);
107
108
  }
108
109
 
109
- /**
110
- * Persist every mutation regardless of status — paused mutations are
111
- * exactly the ones that must survive restart to replay when online.
112
- */
113
- function shouldDehydrateMutation(_mutation: Mutation): boolean {
114
- return true;
115
- }
116
110
 
117
111
  /**
118
112
  * Create a QueryClient with offline-first defaults.
@@ -191,6 +185,12 @@ export interface AttachPersistenceResult {
191
185
  export const attachQueryPersistence = (
192
186
  queryClient: QueryClient,
193
187
  storage: StorageInterface | null | undefined,
188
+ /**
189
+ * Mutation keys the app declared account-scoped (`accountQueries`). Those are
190
+ * persisted per account by `createAccountQueryPersistence`, never here, so a
191
+ * paused mutation cannot replay twice or under another account.
192
+ */
193
+ accountMutationKeys: readonly QueryKey[] = [],
194
194
  ): AttachPersistenceResult => {
195
195
  if (!storage) {
196
196
  return {
@@ -205,10 +205,19 @@ export const attachQueryPersistence = (
205
205
  throttleTime: QUERY_PERSIST_THROTTLE_MS,
206
206
  });
207
207
 
208
+ // Persist every other mutation regardless of status: paused mutations are
209
+ // exactly the ones that must survive restart to replay when online.
210
+ const accountScoped = new Set(accountMutationKeys.map((key) => hashKey(key)));
211
+ const shouldDehydrateMutation = (mutation: Mutation): boolean => {
212
+ const key = mutation.options.mutationKey;
213
+ return key === undefined || !accountScoped.has(hashKey(key));
214
+ };
215
+
208
216
  const [unsubscribe, restored] = persistQueryClient({
209
217
  queryClient,
210
218
  persister,
211
219
  maxAge: QUERY_CACHE_MAX_AGE,
220
+ buster: getOxyBuildId(),
212
221
  dehydrateOptions: {
213
222
  shouldDehydrateQuery,
214
223
  shouldDehydrateMutation,
package/src/ui/index.ts CHANGED
@@ -24,6 +24,8 @@ export { LogoIcon } from './components/logo/LogoIcon';
24
24
  export { LogoText } from './components/logo/LogoText';
25
25
  export { RequireOxyAuth } from './components/RequireOxyAuth';
26
26
  export type { RequireOxyAuthProps, RequireOxyAuthPrompt } from './components/RequireOxyAuth';
27
+ // `OxyProvider`'s `accountQueries`: the app's per-account offline cache.
28
+ export type { AccountQueriesConfig } from './hooks/accountQueryPersistence';
27
29
  export { default as FollowButton } from './components/FollowButton';
28
30
  export { default as PeableButton } from './components/PeableButton';
29
31
  export type { PeableButtonProps } from './components/PeableButton';
@@ -1,5 +1,6 @@
1
1
  import type { ReactNode, RefObject } from 'react';
2
2
  import type { QueryClient } from '@tanstack/react-query';
3
+ import type { AccountQueriesConfig } from '../hooks/accountQueryPersistence';
3
4
  import type { RouteName } from '../navigation/routes';
4
5
  import type { User } from '@oxy.so/core';
5
6
  import type { ClientSession } from '@oxy.so/core';
@@ -131,6 +132,14 @@ export interface OxyProviderProps {
131
132
  */
132
133
  webAuthMode?: WebAuthMode;
133
134
  queryClient?: QueryClient;
135
+ /**
136
+ * The app's private, per-account data, persisted offline by the SDK: stored
137
+ * per account, discarded by every new build, dropped from memory on an
138
+ * account switch and deleted on sign-out. `RequireOxyAuth` waits for the
139
+ * signed-in account's cache to restore, so another account's rows never
140
+ * render. Works with the SDK's client or one passed as `queryClient`.
141
+ */
142
+ accountQueries?: AccountQueriesConfig;
134
143
  /**
135
144
  * Convenience: wrap the whole app subtree in `<RequireOxyAuth prompt=...>`.
136
145
  * `off` (default) renders children unconditionally; `soft` adds a dismissible
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The identity of the JS bundle that is running, used as the buster for every
3
+ * persisted query cache the SDK owns.
4
+ *
5
+ * A persisted cache is restored and served before any refetch (offline-first),
6
+ * so data written by an older build reaches components in whatever shape that
7
+ * build used. Busting on the build means no app ever has to remember to bump a
8
+ * version when a query's shape changes: a new build simply starts from the
9
+ * network. Inbox shipped exactly that crash when a hand-kept buster was missed.
10
+ *
11
+ * `@oxy.so/app-preset`'s Babel config replaces `process.env.OXY_BUILD_ID` with
12
+ * the commit at bundle time, node_modules included. An app that does not use
13
+ * the preset gets a constant, which behaves like a buster that is never bumped.
14
+ */
15
+ export function getOxyBuildId(): string {
16
+ const buildId = typeof process === 'undefined' ? undefined : process.env.OXY_BUILD_ID;
17
+ return buildId || 'unversioned';
18
+ }