@oxy.so/services 9.0.0 → 9.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 (42) hide show
  1. package/lib/commonjs/ui/components/OxyProvider.js +2 -2
  2. package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
  3. package/lib/commonjs/ui/context/OxyContext.js +4 -0
  4. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  5. package/lib/commonjs/ui/hooks/accountQueryPersistence.js +110 -80
  6. package/lib/commonjs/ui/hooks/accountQueryPersistence.js.map +1 -1
  7. package/lib/commonjs/ui/hooks/persistedQueryCache.js +101 -0
  8. package/lib/commonjs/ui/hooks/persistedQueryCache.js.map +1 -0
  9. package/lib/commonjs/ui/hooks/queryClient.js +43 -80
  10. package/lib/commonjs/ui/hooks/queryClient.js.map +1 -1
  11. package/lib/module/ui/components/OxyProvider.js +2 -2
  12. package/lib/module/ui/components/OxyProvider.js.map +1 -1
  13. package/lib/module/ui/context/OxyContext.js +4 -0
  14. package/lib/module/ui/context/OxyContext.js.map +1 -1
  15. package/lib/module/ui/hooks/accountQueryPersistence.js +109 -80
  16. package/lib/module/ui/hooks/accountQueryPersistence.js.map +1 -1
  17. package/lib/module/ui/hooks/persistedQueryCache.js +94 -0
  18. package/lib/module/ui/hooks/persistedQueryCache.js.map +1 -0
  19. package/lib/module/ui/hooks/queryClient.js +44 -81
  20. package/lib/module/ui/hooks/queryClient.js.map +1 -1
  21. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  22. package/lib/typescript/commonjs/ui/hooks/accountQueryPersistence.d.ts +31 -13
  23. package/lib/typescript/commonjs/ui/hooks/accountQueryPersistence.d.ts.map +1 -1
  24. package/lib/typescript/commonjs/ui/hooks/persistedQueryCache.d.ts +44 -0
  25. package/lib/typescript/commonjs/ui/hooks/persistedQueryCache.d.ts.map +1 -0
  26. package/lib/typescript/commonjs/ui/hooks/queryClient.d.ts +15 -20
  27. package/lib/typescript/commonjs/ui/hooks/queryClient.d.ts.map +1 -1
  28. package/lib/typescript/commonjs/ui/icons/MaterialCommunityIcons.d.ts +1 -1
  29. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  30. package/lib/typescript/module/ui/hooks/accountQueryPersistence.d.ts +31 -13
  31. package/lib/typescript/module/ui/hooks/accountQueryPersistence.d.ts.map +1 -1
  32. package/lib/typescript/module/ui/hooks/persistedQueryCache.d.ts +44 -0
  33. package/lib/typescript/module/ui/hooks/persistedQueryCache.d.ts.map +1 -0
  34. package/lib/typescript/module/ui/hooks/queryClient.d.ts +15 -20
  35. package/lib/typescript/module/ui/hooks/queryClient.d.ts.map +1 -1
  36. package/lib/typescript/module/ui/icons/MaterialCommunityIcons.d.ts +1 -1
  37. package/package.json +1 -1
  38. package/src/ui/components/OxyProvider.tsx +2 -2
  39. package/src/ui/context/OxyContext.tsx +4 -0
  40. package/src/ui/hooks/accountQueryPersistence.ts +138 -91
  41. package/src/ui/hooks/persistedQueryCache.ts +126 -0
  42. package/src/ui/hooks/queryClient.ts +55 -98
@@ -8,29 +8,43 @@
8
8
  *
9
9
  * - Storage is keyed by the account (`oxy_account_queries:<accountId>`), so a
10
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
11
+ * - Queries written by another build are dropped on restore; queued (paused)
12
+ * mutations are kept (`persistedQueryCache.ts`).
13
+ * - On a subject change the account's queries, memory-only ones included, and
14
+ * its paused mutations are dropped from memory synchronously, before anyone
15
+ * is woken. The leaving account's latest state is written first, a sign-out
16
+ * then deletes it, and only then is the next account's blob read.
17
+ * - `isReady()` is false until the signed-in account's blob is restored, and
17
18
  * `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
+ * Every storage operation runs through one queue, in order, and a restore
21
+ * checks that its account is still the active one in the same tick it hydrates.
19
22
  */
20
23
 
21
24
  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
25
  import type { StorageInterface } from '../utils/storageHelpers';
25
- import { getOxyBuildId } from '../utils/buildId';
26
+ import {
27
+ PERSISTED_QUERY_PREFIXES,
28
+ SDK_MEMORY_ONLY_ROOTS,
29
+ hydrateSnapshot,
30
+ rootOf,
31
+ snapshot,
32
+ subscribeSnapshots,
33
+ type PersistFilters,
34
+ } from './persistedQueryCache';
26
35
 
27
36
  /** An app's declaration of which data is private to the signed-in account. */
28
37
  export interface AccountQueriesConfig {
29
38
  /**
30
39
  * Query-key roots (`queryKey[0]`) whose data belongs to the signed-in account
31
- * and survives a restart.
40
+ * and survives a restart. `'all'`: every query the app runs, for an app whose
41
+ * every read may depend on who is signed in, so no root can be forgotten.
42
+ *
43
+ * The roots `accounts`, `sessions` and `devices` are reserved for the SDK's
44
+ * device-level data: an app must not use them. `users` and `privacy` are the
45
+ * SDK's too, and with `'all'` they are dropped on a switch like the app's.
32
46
  */
33
- roots: readonly string[];
47
+ roots: readonly string[] | 'all';
34
48
  /**
35
49
  * Roots that belong to the account too, but must not be written to disk
36
50
  * (signed URLs, AI output, search results): dropped on a switch, never persisted.
@@ -43,6 +57,46 @@ export interface AccountQueriesConfig {
43
57
  mutationKeys?: readonly QueryKey[];
44
58
  }
45
59
 
60
+ /** Which queries and mutations an `AccountQueriesConfig` makes the account's. */
61
+ export interface AccountQueryOwnership {
62
+ /** Dropped from memory on a subject change. */
63
+ ownsQuery: (query: Query) => boolean;
64
+ /** Written to the account's blob. */
65
+ persistsQuery: (query: Query) => boolean;
66
+ ownsMutation: (mutation: Mutation) => boolean;
67
+ }
68
+
69
+ /** SDK roots that hold device-level data, the same whoever is signed in. */
70
+ const DEVICE_ROOTS = new Set(['accounts', 'sessions', 'devices']);
71
+
72
+ export function accountQueryOwnership(config: AccountQueriesConfig): AccountQueryOwnership {
73
+ const memoryOnly = new Set([...(config.memoryOnlyRoots ?? []), ...SDK_MEMORY_ONLY_ROOTS]);
74
+ const sdkRoots = new Set(PERSISTED_QUERY_PREFIXES);
75
+ const declared = config.roots === 'all' ? null : new Set(config.roots);
76
+ const mutationHashes = new Set((config.mutationKeys ?? []).map((key) => hashKey(key)));
77
+
78
+ const ownsQuery = (query: Query): boolean => {
79
+ const root = rootOf(query);
80
+ if (root !== null && memoryOnly.has(root)) return true;
81
+ if (declared === null) return root === null || !DEVICE_ROOTS.has(root);
82
+ return root !== null && declared.has(root);
83
+ };
84
+ return {
85
+ ownsQuery,
86
+ persistsQuery: (query) => {
87
+ if (!ownsQuery(query)) return false;
88
+ const root = rootOf(query);
89
+ if (root === null) return true;
90
+ // The SDK's own roots stay with the SDK persister.
91
+ return !memoryOnly.has(root) && (declared !== null || !sdkRoots.has(root));
92
+ },
93
+ ownsMutation: (mutation) => {
94
+ const key = mutation.options.mutationKey;
95
+ return key !== undefined && mutationHashes.has(hashKey(key));
96
+ },
97
+ };
98
+ }
99
+
46
100
  export const ACCOUNT_QUERY_CACHE_KEY = 'oxy_account_queries';
47
101
  const ACCOUNT_QUERY_CACHE_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
48
102
  const ACCOUNT_QUERY_PERSIST_THROTTLE_MS = 1_000;
@@ -52,12 +106,13 @@ export interface AccountQueryPersistence {
52
106
  setStorage(storage: StorageInterface): void;
53
107
  /** The signed-in account changed. `null` is a sign-out. Synchronous in-memory reset. */
54
108
  activate(accountId: string | null): void;
109
+ /** The owning provider mounted: restore and persist the active account. */
110
+ attach(): void;
111
+ /** The owning provider unmounted: stop touching the client and storage. */
112
+ detach(): void;
55
113
  /** True once the active account's cache is restored (always true signed out). */
56
114
  isReady(): boolean;
57
115
  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
116
  }
62
117
 
63
118
  function storageKeyFor(accountId: string): string {
@@ -68,36 +123,37 @@ export function createAccountQueryPersistence(
68
123
  queryClient: QueryClient,
69
124
  config: AccountQueriesConfig,
70
125
  ): 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)));
126
+ const { ownsQuery, persistsQuery, ownsMutation } = accountQueryOwnership(config);
127
+ const filters: PersistFilters = {
128
+ shouldDehydrateQuery: (query) => query.state.status === 'success' && persistsQuery(query),
129
+ shouldDehydrateMutation: (mutation) => mutation.state.isPaused && ownsMutation(mutation),
130
+ };
74
131
  const listeners = new Set<() => void>();
75
132
 
76
- let storage: StorageInterface | null = null;
133
+ let resolveStorage: (storage: StorageInterface) => void = () => undefined;
134
+ const storageReady = new Promise<StorageInterface>((resolve) => {
135
+ resolveStorage = resolve;
136
+ });
137
+ let hasStorage = false;
138
+ // Every read, write and delete runs here, in order, once storage exists.
139
+ let io: Promise<unknown> = storageReady;
140
+ const enqueue = (operation: (storage: StorageInterface) => Promise<unknown> | unknown) => {
141
+ io = io
142
+ .then(() => storageReady)
143
+ .then(operation)
144
+ .catch(() => undefined);
145
+ };
146
+
147
+ let attached = false;
77
148
  let accountId: string | null = null;
78
149
  let restoredFor: string | null = null;
79
150
  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();
151
+ let writer: ReturnType<typeof subscribeSnapshots> | null = null;
84
152
 
85
153
  const notify = () => {
86
154
  for (const listener of listeners) listener();
87
155
  };
88
156
 
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
157
  const dropScopedData = () => {
102
158
  queryClient.removeQueries({ predicate: ownsQuery });
103
159
  const mutations = queryClient.getMutationCache();
@@ -106,77 +162,76 @@ export function createAccountQueryPersistence(
106
162
  }
107
163
  };
108
164
 
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
- });
165
+ const stopWriting = () => {
166
+ writer?.unsubscribe();
167
+ writer = null;
168
+ };
119
169
 
120
- const start = () => {
170
+ const restore = () => {
121
171
  const id = accountId;
122
- const target = storage;
123
- if (!id || !target || unsubscribePersist) return;
172
+ if (!attached || id === null) return;
124
173
  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(() => {
174
+ const key = storageKeyFor(id);
175
+ enqueue(async (storage) => {
176
+ const raw = await storage.getItem(key);
177
+ // Same tick as the hydrate: a switch in between cannot be missed.
137
178
  if (current !== generation) return;
179
+ if (!hydrateSnapshot(queryClient, raw, ACCOUNT_QUERY_CACHE_MAX_AGE) && raw) {
180
+ await storage.removeItem(key);
181
+ if (current !== generation) return;
182
+ }
138
183
  restoredFor = id;
184
+ writer = subscribeSnapshots(queryClient, filters, ACCOUNT_QUERY_PERSIST_THROTTLE_MS, (serialized) => {
185
+ if (current !== generation) return;
186
+ enqueue((target) => target.setItem(key, serialized));
187
+ });
139
188
  notify();
140
189
  });
141
190
  };
142
191
 
143
- const stop = () => {
144
- unsubscribePersist?.();
145
- unsubscribePersist = null;
146
- };
147
-
148
192
  return {
149
193
  setStorage(next) {
150
- if (storage) return;
151
- storage = next;
152
- const current = generation;
153
- void restoring.then(() => {
154
- if (current === generation) start();
155
- });
194
+ if (hasStorage) return;
195
+ hasStorage = true;
196
+ resolveStorage(next);
156
197
  },
157
198
 
158
199
  activate(next) {
159
200
  if (next === accountId) return;
160
201
  const previous = accountId;
202
+ // Keep the leaving account's latest state, but only once its own blob was
203
+ // restored: before that, the cache holds none of it to write.
204
+ if (previous !== null && restoredFor === previous) {
205
+ const serialized = snapshot(queryClient, filters);
206
+ const key = storageKeyFor(previous);
207
+ enqueue((storage) => storage.setItem(key, serialized));
208
+ }
161
209
  generation += 1;
162
- const current = generation;
163
- stop();
210
+ stopWriting();
164
211
  dropScopedData();
165
212
  accountId = next;
166
213
  restoredFor = null;
214
+ if (next === null && previous !== null) {
215
+ const key = storageKeyFor(previous);
216
+ enqueue((storage) => storage.removeItem(key));
217
+ }
167
218
  notify();
219
+ restore();
220
+ },
168
221
 
169
- if (next === null && previous !== null && storage) {
170
- void persisterFor(previous, storage).removeClient();
171
- }
222
+ attach() {
223
+ if (attached) return;
224
+ attached = true;
225
+ restore();
226
+ },
172
227
 
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
- });
228
+ detach() {
229
+ if (!attached) return;
230
+ attached = false;
231
+ writer?.flush();
232
+ generation += 1;
233
+ stopWriting();
234
+ restoredFor = null;
180
235
  },
181
236
 
182
237
  isReady() {
@@ -189,13 +244,5 @@ export function createAccountQueryPersistence(
189
244
  listeners.delete(listener);
190
245
  };
191
246
  },
192
-
193
- ownsMutation,
194
-
195
- dispose() {
196
- generation += 1;
197
- stop();
198
- listeners.clear();
199
- },
200
247
  };
201
248
  }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The storage format and restore rules shared by the SDK's two query caches:
3
+ * the account-agnostic one (`attachQueryPersistence`) and the per-account one
4
+ * (`createAccountQueryPersistence`).
5
+ *
6
+ * A blob is `{ buster, timestamp, clientState }`, TanStack's persisted-client
7
+ * shape. Restoring it treats its two halves differently:
8
+ *
9
+ * - Queries are served before any refetch, so a query written by another
10
+ * build, in whatever shape that build used, is dropped (`getOxyBuildId`).
11
+ * - Paused mutations are the user's queued actions (a star, a send made
12
+ * offline). A deploy must not lose them, so they are restored whatever build
13
+ * wrote them; the current build's `setMutationDefaults` replays them.
14
+ */
15
+
16
+ import {
17
+ dehydrate,
18
+ hydrate,
19
+ type DehydratedState,
20
+ type Mutation,
21
+ type Query,
22
+ type QueryClient,
23
+ } from '@tanstack/react-query';
24
+ import { getOxyBuildId } from '../utils/buildId';
25
+
26
+ /** The SDK's own account-agnostic query roots, persisted by `attachQueryPersistence`. */
27
+ export const PERSISTED_QUERY_PREFIXES: ReadonlyArray<string> = [
28
+ 'accounts',
29
+ 'users',
30
+ 'sessions',
31
+ 'devices',
32
+ 'privacy',
33
+ ];
34
+
35
+ /**
36
+ * SDK queries whose data (or key) carries a signed, expiring URL. Never
37
+ * written to disk: a restored one would serve an expired URL.
38
+ */
39
+ export const SDK_MEMORY_ONLY_ROOTS: ReadonlyArray<string> = [
40
+ 'assetDownloadUrls',
41
+ 'avatarCropSource',
42
+ 'avatarCropMeasure',
43
+ 'justifiedPhotoDimensions',
44
+ ];
45
+
46
+ export interface PersistFilters {
47
+ shouldDehydrateQuery: (query: Query) => boolean;
48
+ shouldDehydrateMutation: (mutation: Mutation) => boolean;
49
+ }
50
+
51
+ interface PersistedBlob {
52
+ buster: string;
53
+ timestamp: number;
54
+ clientState: DehydratedState;
55
+ }
56
+
57
+ export function rootOf(query: Query): string | null {
58
+ const head = query.queryKey[0];
59
+ return typeof head === 'string' ? head : null;
60
+ }
61
+
62
+ /** Serialize what the filters select, stamped with this build. */
63
+ export function snapshot(queryClient: QueryClient, filters: PersistFilters): string {
64
+ const blob: PersistedBlob = {
65
+ buster: getOxyBuildId(),
66
+ timestamp: Date.now(),
67
+ clientState: dehydrate(queryClient, filters),
68
+ };
69
+ return JSON.stringify(blob);
70
+ }
71
+
72
+ /**
73
+ * Hydrate a stored blob into the client, synchronously, so a caller can check
74
+ * it is still wanted immediately before. Returns false when there was nothing
75
+ * usable (absent, unreadable or expired), in which case the caller removes it.
76
+ */
77
+ export function hydrateSnapshot(queryClient: QueryClient, raw: string | null, maxAge: number): boolean {
78
+ if (!raw) return false;
79
+ let blob: PersistedBlob;
80
+ try {
81
+ blob = JSON.parse(raw) as PersistedBlob;
82
+ } catch {
83
+ return false;
84
+ }
85
+ if (!blob?.clientState || typeof blob.timestamp !== 'number' || Date.now() - blob.timestamp > maxAge) {
86
+ return false;
87
+ }
88
+ const sameBuild = blob.buster === getOxyBuildId();
89
+ hydrate(queryClient, {
90
+ queries: sameBuild ? blob.clientState.queries : [],
91
+ mutations: blob.clientState.mutations ?? [],
92
+ });
93
+ return true;
94
+ }
95
+
96
+ /**
97
+ * Write the filtered cache on every change, at most once per `throttleMs`.
98
+ * `write` receives the serialized snapshot; `flush()` writes now.
99
+ */
100
+ export function subscribeSnapshots(
101
+ queryClient: QueryClient,
102
+ filters: PersistFilters,
103
+ throttleMs: number,
104
+ write: (serialized: string) => void,
105
+ ): { flush: () => void; unsubscribe: () => void } {
106
+ let timer: ReturnType<typeof setTimeout> | null = null;
107
+ const flush = () => {
108
+ if (timer) clearTimeout(timer);
109
+ timer = null;
110
+ write(snapshot(queryClient, filters));
111
+ };
112
+ const schedule = () => {
113
+ if (!timer) timer = setTimeout(flush, throttleMs);
114
+ };
115
+ const offQueries = queryClient.getQueryCache().subscribe(schedule);
116
+ const offMutations = queryClient.getMutationCache().subscribe(schedule);
117
+ return {
118
+ flush,
119
+ unsubscribe: () => {
120
+ if (timer) clearTimeout(timer);
121
+ timer = null;
122
+ offQueries();
123
+ offMutations();
124
+ },
125
+ };
126
+ }
@@ -5,34 +5,28 @@
5
5
  * - TanStack Query with `networkMode: 'offlineFirst'` for queries and mutations
6
6
  * so cached data is served immediately and mutations are queued (paused) while
7
7
  * the browser/device reports offline.
8
- * - `persistQueryClient(...)` from `@tanstack/react-query-persist-client` so that
9
- * query results AND paused mutations survive a cold restart (kill-and-relaunch).
8
+ * - `attachQueryPersistence(...)` so that query results AND paused mutations
9
+ * survive a cold restart (kill-and-relaunch); see `persistedQueryCache.ts`.
10
10
  * - `onlineManager` resume hook so paused mutations replay the moment the
11
11
  * network is reported back, even if the host app swapped in a custom
12
12
  * onlineManager implementation.
13
13
  *
14
- * Storage layer:
15
- * - React Native -> AsyncStorage via `createAsyncStoragePersister`.
16
- * - Web -> localStorage via `createSyncStoragePersister` (wrapped in the
17
- * async persister API for a single call site).
18
- * - Both adapters are content-shape compatible with our `StorageInterface`.
14
+ * Storage layer: the platform `StorageInterface` (AsyncStorage on native,
15
+ * localStorage on web).
19
16
  *
20
17
  * Whitelist policy:
21
18
  * - Persist every account/user/session/privacy query and every queued mutation.
22
19
  * - DO NOT persist large list queries (e.g. activity feeds) — they go stale
23
20
  * fast and would balloon storage. Add new keys to `PERSISTED_QUERY_PREFIXES`
24
- * when introducing reads that should survive restart.
21
+ * (`persistedQueryCache.ts`) when introducing reads that should survive restart.
25
22
  */
26
23
 
27
- import { QueryClient, hashKey, onlineManager, type Query, type Mutation, type QueryKey } from '@tanstack/react-query';
28
- import {
29
- persistQueryClient,
30
- type PersistedClient,
31
- } from '@tanstack/react-query-persist-client';
32
- import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
24
+ import { QueryClient, onlineManager, type Mutation, type Query } from '@tanstack/react-query';
25
+ import type { PersistedClient } from '@tanstack/react-query-persist-client';
33
26
  import { isDev } from '@oxy.so/core';
34
27
  import type { StorageInterface } from '../utils/storageHelpers';
35
- import { getOxyBuildId } from '../utils/buildId';
28
+ import { accountQueryOwnership, type AccountQueriesConfig } from './accountQueryPersistence';
29
+ import { PERSISTED_QUERY_PREFIXES, hydrateSnapshot, subscribeSnapshots } from './persistedQueryCache';
36
30
 
37
31
  const QUERY_CACHE_KEY = 'oxy_query_cache_v3';
38
32
  const QUERY_CACHE_MAX_AGE = 30 * 24 * 60 * 60 * 1000; // 30 days
@@ -55,59 +49,18 @@ export function shouldRetryQuery(failureCount: number, error: unknown): boolean
55
49
  }
56
50
 
57
51
  /**
58
- * Query-key prefixes that should survive cold restart. Anything not listed
59
- * is dropped during dehydration so the persisted blob stays small.
60
- *
61
- * Mutations are persisted independently (always) so the offline write queue
62
- * works regardless of the read whitelist.
63
- */
64
- const PERSISTED_QUERY_PREFIXES: ReadonlyArray<string> = [
65
- 'accounts',
66
- 'users',
67
- 'sessions',
68
- 'devices',
69
- 'privacy',
70
- ];
71
-
72
- /**
73
- * Adapt our `StorageInterface` (which always returns `null` for missing keys)
74
- * to TanStack's `AsyncStorage` shape. The two are structurally identical
75
- * apart from naming; this also gives us a single place to add error
76
- * suppression if a host platform's storage throws.
77
- */
78
- function adaptStorage(storage: StorageInterface): {
79
- getItem: (key: string) => Promise<string | null>;
80
- setItem: (key: string, value: string) => Promise<void>;
81
- removeItem: (key: string) => Promise<void>;
82
- } {
83
- return {
84
- getItem: (key) => storage.getItem(key),
85
- setItem: (key, value) => storage.setItem(key, value),
86
- removeItem: (key) => storage.removeItem(key),
87
- };
88
- }
89
-
90
- /**
91
- * Decide whether a given query should be written to persistent storage.
92
- *
93
- * Two gates:
94
- * 1. The query must be in our prefix whitelist.
95
- * 2. The query must have completed successfully at least once — there's no
96
- * point persisting a `pending`/`error` state, and persisting `error` would
97
- * leak failure objects across restarts.
52
+ * Whether a query belongs in the account-agnostic cache: successful (a
53
+ * `pending`/`error` state is not worth keeping, and an error would leak
54
+ * failure objects across restarts) and under one of the SDK's own prefixes.
98
55
  */
99
- function shouldDehydrateQuery(query: Query): boolean {
56
+ function isSharedQuery(query: Query): boolean {
100
57
  if (query.state.status !== 'success') {
101
58
  return false;
102
59
  }
103
60
  const head = query.queryKey[0];
104
- if (typeof head !== 'string') {
105
- return false;
106
- }
107
- return PERSISTED_QUERY_PREFIXES.includes(head);
61
+ return typeof head === 'string' && PERSISTED_QUERY_PREFIXES.includes(head);
108
62
  }
109
63
 
110
-
111
64
  /**
112
65
  * Create a QueryClient with offline-first defaults.
113
66
  *
@@ -174,10 +127,13 @@ export interface AttachPersistenceResult {
174
127
  }
175
128
 
176
129
  /**
177
- * Wire `persistQueryClient` to the supplied storage adapter.
130
+ * Restore, then keep writing, the account-agnostic cache.
178
131
  *
179
- * Returns once the in-flight restore promise is available so callers can
180
- * `await result.restored` before rendering UI that depends on cached data.
132
+ * Queries written by another build are dropped; queued mutations are kept
133
+ * (`persistedQueryCache.ts`). Whatever the app declared account-scoped
134
+ * (`accountQueries`) is left to `createAccountQueryPersistence`, even under
135
+ * one of the SDK's prefixes, so private rows never land in the shared blob and
136
+ * a paused mutation cannot replay twice or under another account.
181
137
  *
182
138
  * Safe to no-op if `storage` is null/undefined (e.g. server-side render
183
139
  * with no host storage).
@@ -185,12 +141,7 @@ export interface AttachPersistenceResult {
185
141
  export const attachQueryPersistence = (
186
142
  queryClient: QueryClient,
187
143
  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[] = [],
144
+ accountQueries?: AccountQueriesConfig,
194
145
  ): AttachPersistenceResult => {
195
146
  if (!storage) {
196
147
  return {
@@ -199,38 +150,44 @@ export const attachQueryPersistence = (
199
150
  };
200
151
  }
201
152
 
202
- const persister = createAsyncStoragePersister({
203
- storage: adaptStorage(storage),
204
- key: QUERY_CACHE_KEY,
205
- throttleTime: QUERY_PERSIST_THROTTLE_MS,
206
- });
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));
153
+ const account = accountQueries ? accountQueryOwnership(accountQueries) : null;
154
+ const filters = {
155
+ shouldDehydrateQuery: (query: Query) =>
156
+ isSharedQuery(query) && !(account?.ownsQuery(query) ?? false),
157
+ // Every other mutation, whatever its status: paused ones are exactly the
158
+ // ones that must survive a restart to replay when online.
159
+ shouldDehydrateMutation: (mutation: Mutation) =>
160
+ !(account?.ownsMutation(mutation) ?? false),
214
161
  };
215
162
 
216
- const [unsubscribe, restored] = persistQueryClient({
217
- queryClient,
218
- persister,
219
- maxAge: QUERY_CACHE_MAX_AGE,
220
- buster: getOxyBuildId(),
221
- dehydrateOptions: {
222
- shouldDehydrateQuery,
223
- shouldDehydrateMutation,
224
- },
225
- });
226
-
227
- restored.catch((error) => {
228
- if (isDev()) {
229
- console.warn('[QueryClient] Failed to restore persisted cache', error);
163
+ let stopped = false;
164
+ let writer: ReturnType<typeof subscribeSnapshots> | null = null;
165
+ const restored = (async () => {
166
+ const raw = await storage.getItem(QUERY_CACHE_KEY);
167
+ if (stopped) return;
168
+ if (!hydrateSnapshot(queryClient, raw, QUERY_CACHE_MAX_AGE) && raw) {
169
+ await storage.removeItem(QUERY_CACHE_KEY);
230
170
  }
231
- });
171
+ })()
172
+ .catch((error) => {
173
+ if (isDev()) {
174
+ console.warn('[QueryClient] Failed to restore persisted cache', error);
175
+ }
176
+ })
177
+ .then(() => {
178
+ if (stopped) return;
179
+ writer = subscribeSnapshots(queryClient, filters, QUERY_PERSIST_THROTTLE_MS, (serialized) => {
180
+ void Promise.resolve(storage.setItem(QUERY_CACHE_KEY, serialized)).catch(() => undefined);
181
+ });
182
+ });
232
183
 
233
- return { unsubscribe, restored };
184
+ return {
185
+ restored,
186
+ unsubscribe: () => {
187
+ stopped = true;
188
+ writer?.unsubscribe();
189
+ },
190
+ };
234
191
  };
235
192
 
236
193
  /**