@oxy.so/services 8.4.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.
- package/lib/commonjs/ui/components/BenefitList.js +1 -2
- package/lib/commonjs/ui/components/BenefitList.js.map +1 -1
- package/lib/commonjs/ui/components/OxyProvider.js +2 -2
- package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
- package/lib/commonjs/ui/context/OxyContext.js +4 -0
- package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
- package/lib/commonjs/ui/hooks/accountQueryPersistence.js +110 -80
- package/lib/commonjs/ui/hooks/accountQueryPersistence.js.map +1 -1
- package/lib/commonjs/ui/hooks/persistedQueryCache.js +101 -0
- package/lib/commonjs/ui/hooks/persistedQueryCache.js.map +1 -0
- package/lib/commonjs/ui/hooks/queryClient.js +43 -80
- package/lib/commonjs/ui/hooks/queryClient.js.map +1 -1
- package/lib/module/ui/components/BenefitList.js +1 -2
- package/lib/module/ui/components/BenefitList.js.map +1 -1
- package/lib/module/ui/components/OxyProvider.js +2 -2
- package/lib/module/ui/components/OxyProvider.js.map +1 -1
- package/lib/module/ui/context/OxyContext.js +4 -0
- package/lib/module/ui/context/OxyContext.js.map +1 -1
- package/lib/module/ui/hooks/accountQueryPersistence.js +109 -80
- package/lib/module/ui/hooks/accountQueryPersistence.js.map +1 -1
- package/lib/module/ui/hooks/persistedQueryCache.js +94 -0
- package/lib/module/ui/hooks/persistedQueryCache.js.map +1 -0
- package/lib/module/ui/hooks/queryClient.js +44 -81
- package/lib/module/ui/hooks/queryClient.js.map +1 -1
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/accountQueryPersistence.d.ts +31 -13
- package/lib/typescript/commonjs/ui/hooks/accountQueryPersistence.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/persistedQueryCache.d.ts +44 -0
- package/lib/typescript/commonjs/ui/hooks/persistedQueryCache.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/hooks/queryClient.d.ts +15 -20
- package/lib/typescript/commonjs/ui/hooks/queryClient.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/icons/MaterialCommunityIcons.d.ts +1 -1
- package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/accountQueryPersistence.d.ts +31 -13
- package/lib/typescript/module/ui/hooks/accountQueryPersistence.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/persistedQueryCache.d.ts +44 -0
- package/lib/typescript/module/ui/hooks/persistedQueryCache.d.ts.map +1 -0
- package/lib/typescript/module/ui/hooks/queryClient.d.ts +15 -20
- package/lib/typescript/module/ui/hooks/queryClient.d.ts.map +1 -1
- package/lib/typescript/module/ui/icons/MaterialCommunityIcons.d.ts +1 -1
- package/package.json +4 -4
- package/src/ui/components/BenefitList.tsx +1 -1
- package/src/ui/components/OxyProvider.tsx +2 -2
- package/src/ui/context/OxyContext.tsx +4 -0
- package/src/ui/hooks/accountQueryPersistence.ts +138 -91
- package/src/ui/hooks/persistedQueryCache.ts +126 -0
- package/src/ui/hooks/queryClient.ts +55 -98
|
@@ -5,27 +5,25 @@
|
|
|
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
|
-
* - `
|
|
9
|
-
*
|
|
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
|
-
*
|
|
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
|
-
import { QueryClient
|
|
27
|
-
import {
|
|
23
|
+
import { QueryClient } from '@tanstack/react-query';
|
|
24
|
+
import type { PersistedClient } from '@tanstack/react-query-persist-client';
|
|
28
25
|
import type { StorageInterface } from '../utils/storageHelpers.js';
|
|
26
|
+
import { type AccountQueriesConfig } from './accountQueryPersistence.js';
|
|
29
27
|
/** Retry only failures that another attempt can plausibly change. */
|
|
30
28
|
export declare function shouldRetryQuery(failureCount: number, error: unknown): boolean;
|
|
31
29
|
/**
|
|
@@ -47,21 +45,18 @@ export interface AttachPersistenceResult {
|
|
|
47
45
|
unsubscribe: () => void;
|
|
48
46
|
}
|
|
49
47
|
/**
|
|
50
|
-
*
|
|
48
|
+
* Restore, then keep writing, the account-agnostic cache.
|
|
51
49
|
*
|
|
52
|
-
*
|
|
53
|
-
* `
|
|
50
|
+
* Queries written by another build are dropped; queued mutations are kept
|
|
51
|
+
* (`persistedQueryCache.ts`). Whatever the app declared account-scoped
|
|
52
|
+
* (`accountQueries`) is left to `createAccountQueryPersistence`, even under
|
|
53
|
+
* one of the SDK's prefixes, so private rows never land in the shared blob and
|
|
54
|
+
* a paused mutation cannot replay twice or under another account.
|
|
54
55
|
*
|
|
55
56
|
* Safe to no-op if `storage` is null/undefined (e.g. server-side render
|
|
56
57
|
* with no host storage).
|
|
57
58
|
*/
|
|
58
|
-
export declare const attachQueryPersistence: (queryClient: QueryClient, storage: StorageInterface | null | undefined,
|
|
59
|
-
/**
|
|
60
|
-
* Mutation keys the app declared account-scoped (`accountQueries`). Those are
|
|
61
|
-
* persisted per account by `createAccountQueryPersistence`, never here, so a
|
|
62
|
-
* paused mutation cannot replay twice or under another account.
|
|
63
|
-
*/
|
|
64
|
-
accountMutationKeys?: readonly QueryKey[]) => AttachPersistenceResult;
|
|
59
|
+
export declare const attachQueryPersistence: (queryClient: QueryClient, storage: StorageInterface | null | undefined, accountQueries?: AccountQueriesConfig) => AttachPersistenceResult;
|
|
65
60
|
/**
|
|
66
61
|
* Remove the persisted query+mutation cache (used on full sign-out / data reset).
|
|
67
62
|
* Safe to call even if persistence was never attached.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"queryClient.d.ts","sourceRoot":"","sources":["../../../../../src/ui/hooks/queryClient.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"queryClient.d.ts","sourceRoot":"","sources":["../../../../../src/ui/hooks/queryClient.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,EAAE,WAAW,EAA4C,MAAM,uBAAuB,CAAC;AAC9F,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,sCAAsC,CAAC;AAE5E,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,4BAAyB,CAAC;AAChE,OAAO,EAAyB,KAAK,oBAAoB,EAAE,MAAM,8BAA2B,CAAC;AAe7F,qEAAqE;AACrE,wBAAgB,gBAAgB,CAAC,YAAY,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAK9E;AAeD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,iBAAiB,QAAO,WA6CpC,CAAC;AAEF,MAAM,WAAW,uBAAuB;IACtC,wEAAwE;IACxE,QAAQ,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;IACxB,8DAA8D;IAC9D,WAAW,EAAE,MAAM,IAAI,CAAC;CACzB;AAED;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,sBAAsB,GACjC,aAAa,WAAW,EACxB,SAAS,gBAAgB,GAAG,IAAI,GAAG,SAAS,EAC5C,iBAAiB,oBAAoB,KACpC,uBA8CF,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,eAAe,GAAU,SAAS,gBAAgB,KAAG,OAAO,CAAC,IAAI,CAQ7E,CAAC;AAEF;;GAEG;AACH,YAAY,EAAE,eAAe,EAAE,CAAC"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
/** Glyphs are decorative: hidden from assistive technology (see `decorativeIconSet`). */
|
|
2
|
-
declare const MaterialCommunityIcons: import("@expo/vector-icons/build/createIconSet").Icon<"function" | "sort" | "at" | "label" | "link" | "ellipse" | "image" | "switch" | "text" | "alert" | "alert-circle" | "alert-circle-outline" | "apps" | "archive" | "arrow-down" | "arrow-up" | "briefcase-outline" | "bug" | "calendar" | "calendar-outline" | "camera" | "camera-outline" | "card" | "card-outline" | "cart-outline" | "chevron-down" | "chevron-up" | "close" | "close-circle" | "cloud-outline" | "cloud-upload" | "cloud-upload-outline" | "cog" | "cog-outline" | "cube-outline" | "download" | "download-outline" | "earth" | "ellipse-outline" | "eye" | "eye-off-outline" | "filter-outline" | "flash" | "folder" | "folder-open-outline" | "folder-outline" | "gift" | "gift-outline" | "grid" | "heart" | "heart-outline" | "help-circle" | "help-circle-outline" | "image-outline" | "information" | "information-outline" | "key" | "leaf" | "library" | "pencil-outline" | "play" | "pulse" | "radio" | "receipt-outline" | "resize" | "rocket" | "scale" | "school-outline" | "server" | "share-outline" | "shield" | "star" | "sync" | "trophy" | "trophy-outline" | "volume-off" | "wallet" | "wallet-outline" | "watch" | "wifi" | "solid" | "account" | "account-circle" | "account-cog-outline" | "account-eye-outline" | "account-group" | "account-multiple" | "account-multiple-outline" | "account-outline" | "account-plus" | "account-search" | "account-switch" | "android" | "archive-arrow-down" | "arrow-left" | "bell" | "bell-outline" | "bio" | "bullhorn" | "cake-variant" | "cake-variant-outline" | "cancel" | "cellphone" | "cellphone-basic" | "cellphone-message" | "cellphone-screenshot" | "chart-line" | "check-all" | "check-circle" | "chip" | "circle-outline" | "clock" | "clock-outline" | "code-tags" | "contain" | "content-copy" | "controller" | "cookie" | "credit-card" | "database-outline" | "delete" | "delete-outline" | "
|
|
2
|
+
declare const MaterialCommunityIcons: import("@expo/vector-icons/build/createIconSet").Icon<"function" | "devices" | "sort" | "at" | "label" | "link" | "ellipse" | "image" | "switch" | "text" | "alert" | "alert-circle" | "alert-circle-outline" | "apps" | "archive" | "arrow-down" | "arrow-up" | "briefcase-outline" | "bug" | "calendar" | "calendar-outline" | "camera" | "camera-outline" | "card" | "card-outline" | "cart-outline" | "chevron-down" | "chevron-up" | "close" | "close-circle" | "cloud-outline" | "cloud-upload" | "cloud-upload-outline" | "cog" | "cog-outline" | "cube-outline" | "download" | "download-outline" | "earth" | "ellipse-outline" | "eye" | "eye-off-outline" | "filter-outline" | "flash" | "folder" | "folder-open-outline" | "folder-outline" | "gift" | "gift-outline" | "grid" | "heart" | "heart-outline" | "help-circle" | "help-circle-outline" | "image-outline" | "information" | "information-outline" | "key" | "leaf" | "library" | "pencil-outline" | "play" | "pulse" | "radio" | "receipt-outline" | "resize" | "rocket" | "scale" | "school-outline" | "server" | "share-outline" | "shield" | "star" | "sync" | "trophy" | "trophy-outline" | "volume-off" | "wallet" | "wallet-outline" | "watch" | "wifi" | "solid" | "opacity" | "wrap" | "account" | "account-circle" | "account-cog-outline" | "account-eye-outline" | "account-group" | "account-multiple" | "account-multiple-outline" | "account-outline" | "account-plus" | "account-search" | "account-switch" | "android" | "archive-arrow-down" | "arrow-left" | "bell" | "bell-outline" | "bio" | "bullhorn" | "cake-variant" | "cake-variant-outline" | "cancel" | "cellphone" | "cellphone-basic" | "cellphone-message" | "cellphone-screenshot" | "chart-line" | "check-all" | "check-circle" | "chip" | "circle-outline" | "clock" | "clock-outline" | "code-tags" | "contain" | "content-copy" | "controller" | "cookie" | "credit-card" | "database-outline" | "delete" | "delete-outline" | "dots-horizontal" | "email" | "email-outline" | "file" | "file-document" | "file-document-edit-outline" | "file-document-outline" | "file-excel-outline" | "file-outline" | "file-pdf-box" | "folder-image" | "folder-zip-outline" | "ghost" | "history" | "image-multiple-outline" | "link-variant" | "loading" | "lock-outline" | "logout" | "magnify" | "map-marker" | "map-marker-multiple" | "map-marker-outline" | "message" | "message-outline" | "minus-circle" | "motion" | "music-note-outline" | "network" | "null" | "numeric" | "open-in-app" | "openid" | "palette" | "palette-outline" | "pan" | "phone" | "phone-outline" | "plus" | "plus-circle" | "post" | "presentation" | "react" | "read" | "security" | "shield-account-outline" | "shield-check" | "shield-search" | "signature" | "sort-alphabetical-variant" | "sort-numeric-variant" | "store" | "tag" | "tag-outline" | "text-box-outline" | "theme-light-dark" | "transfer" | "translate" | "trash-can-outline" | "tray-arrow-down" | "update" | "video-outline" | "web" | "wifi-off" | "wrench" | "xml", "OxyServicesMaterialCommunityIcons">;
|
|
3
3
|
export default MaterialCommunityIcons;
|
|
4
4
|
//# sourceMappingURL=MaterialCommunityIcons.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxy.so/services",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "9.1.0",
|
|
4
4
|
"description": "OxyHQ Expo/React Native SDK — UI components, screens, and native features",
|
|
5
5
|
"main": "lib/commonjs/index.js",
|
|
6
6
|
"module": "lib/module/index.js",
|
|
@@ -178,7 +178,7 @@
|
|
|
178
178
|
"@types/jest": "^30.0.0",
|
|
179
179
|
"@types/node": "^20.19.43",
|
|
180
180
|
"@types/react": "*",
|
|
181
|
-
"@oxy.so/bloom": "^
|
|
181
|
+
"@oxy.so/bloom": "^5.1.0",
|
|
182
182
|
"copyfiles": "^2.4.1",
|
|
183
183
|
"expo-clipboard": "~57.0.2",
|
|
184
184
|
"expo-constants": "~57.0.5",
|
|
@@ -212,10 +212,10 @@
|
|
|
212
212
|
"typescript": "^5.9.2",
|
|
213
213
|
"zustand": "^5.0.14"
|
|
214
214
|
},
|
|
215
|
-
"//@oxy.so/bloom": "RE-RAISE THIS FLOOR ON EVERY BREAKING BLOOM RELEASE, in the same commit as the root package.json catalog entry and the code that requires it. The breaking position is the MAJOR, so `^3.0.1` means `>=3.0.1 <4.0.0`: 3.x minors are admitted on semver's additive promise, and Bloom 4.0.0 is refused until someone raises this deliberately. The CAP is the part that is load-bearing. An earlier revision of this key recorded the opposite trade -- an uncapped `>=` floor, accepted so that intentionally-breaking Bloom 0.x minors would not be blocked -- and that trade is closed by evidence rather than by preference: an unbounded range is exactly what let `bun install` pair this package with Bloom 1.0.0 and emit no warning at all, and 1.0.0 broke it four ways (a deleted provider, a renamed confirm option, a renamed Chip fill, and two REMOVED SUBPATHS that fail at bundle time, before any typecheck runs). A floor is a claim about what was measured; a cap is the claim that nothing beyond it was. RAISED TO 3.0.1 from `^2.1.0`. Bloom 3.0.0 breaks exactly two things -- `PageHeader` defaults to `presentation=\"floating\"` and stops hiding the title under `transparent`, and `Field disabled` now reaches the control that reads the field context -- and THIS PACKAGE IMPORTS NEITHER: it renders no `PageHeader` and no `Field`, and 3.0 removes no subpath (unlike 2.0.0, which is why that floor cost code changes and this one does not). Its six `transparent` occurrences are CSS colour values, not the PageHeader prop. The cap had to move rather than widen to a union, because leaving `^2.1.0` in place is what made `bun install` resolve a SECOND, nested Bloom 2.12.4 under this package for any app on Bloom 3 -- two copies of a library whose 3.0 contracts (`control-surface`, `TopEdgeProvider`, `ScrollOffsetProvider`) are React contexts, so a provider from one copy cannot be read by a consumer from the other. Mention's `oxyRuntimeCompatibility` test is what caught it; nothing in a typecheck can. RAISED AGAIN TO 3.2.0 from `^3.0.1`, for the same class of reason the 2.x note records, only in the other direction: 3.2.0 ADDS the per-glyph icon subpaths (`@oxy.so/bloom/icons/RiAtLine`) that this package's screens now import instead of the `@oxy.so/bloom/icons` barrel, and a missing subpath fails at bundle time, in the consumer's Metro, long after this package has typechecked and shipped. `^3.0.1` would have admitted Bloom 3.0.1 and 3.1.0, neither of which has those subpaths, so the floor is the only thing standing between a consumer and a red build. Nothing else about 3.1.0 or 3.2.0 is required here; the cap stays at the major. RAISED TO ^4.2.0: FollowButton now delegates its visuals to Bloom media-header FollowButton with loading, textStyle and large size; the new shared UI contracts require one Bloom4 context across Services and its consumer. RAISED TO ^4.31.0: the sign-in and account screens type one-time codes into `InputOtp` with `type=\"alphanumeric\"` (the long sign-in code and backup codes), which 4.31.0 adds; older 4.x drops the letters. RAISED TO ^4.35.0: every password field passes `revealable` to `TextFieldInput` (the eye button, 4.35.0); older 4.x drops the prop silently and the password cannot be shown. The provider also mounts `@oxy.so/bloom/locale` (4.32.0+).",
|
|
215
|
+
"//@oxy.so/bloom": "RE-RAISE THIS FLOOR ON EVERY BREAKING BLOOM RELEASE, in the same commit as the root package.json catalog entry and the code that requires it. The breaking position is the MAJOR, so `^3.0.1` means `>=3.0.1 <4.0.0`: 3.x minors are admitted on semver's additive promise, and Bloom 4.0.0 is refused until someone raises this deliberately. The CAP is the part that is load-bearing. An earlier revision of this key recorded the opposite trade -- an uncapped `>=` floor, accepted so that intentionally-breaking Bloom 0.x minors would not be blocked -- and that trade is closed by evidence rather than by preference: an unbounded range is exactly what let `bun install` pair this package with Bloom 1.0.0 and emit no warning at all, and 1.0.0 broke it four ways (a deleted provider, a renamed confirm option, a renamed Chip fill, and two REMOVED SUBPATHS that fail at bundle time, before any typecheck runs). A floor is a claim about what was measured; a cap is the claim that nothing beyond it was. RAISED TO 3.0.1 from `^2.1.0`. Bloom 3.0.0 breaks exactly two things -- `PageHeader` defaults to `presentation=\"floating\"` and stops hiding the title under `transparent`, and `Field disabled` now reaches the control that reads the field context -- and THIS PACKAGE IMPORTS NEITHER: it renders no `PageHeader` and no `Field`, and 3.0 removes no subpath (unlike 2.0.0, which is why that floor cost code changes and this one does not). Its six `transparent` occurrences are CSS colour values, not the PageHeader prop. The cap had to move rather than widen to a union, because leaving `^2.1.0` in place is what made `bun install` resolve a SECOND, nested Bloom 2.12.4 under this package for any app on Bloom 3 -- two copies of a library whose 3.0 contracts (`control-surface`, `TopEdgeProvider`, `ScrollOffsetProvider`) are React contexts, so a provider from one copy cannot be read by a consumer from the other. Mention's `oxyRuntimeCompatibility` test is what caught it; nothing in a typecheck can. RAISED AGAIN TO 3.2.0 from `^3.0.1`, for the same class of reason the 2.x note records, only in the other direction: 3.2.0 ADDS the per-glyph icon subpaths (`@oxy.so/bloom/icons/RiAtLine`) that this package's screens now import instead of the `@oxy.so/bloom/icons` barrel, and a missing subpath fails at bundle time, in the consumer's Metro, long after this package has typechecked and shipped. `^3.0.1` would have admitted Bloom 3.0.1 and 3.1.0, neither of which has those subpaths, so the floor is the only thing standing between a consumer and a red build. Nothing else about 3.1.0 or 3.2.0 is required here; the cap stays at the major. RAISED TO ^4.2.0: FollowButton now delegates its visuals to Bloom media-header FollowButton with loading, textStyle and large size; the new shared UI contracts require one Bloom4 context across Services and its consumer. RAISED TO ^4.31.0: the sign-in and account screens type one-time codes into `InputOtp` with `type=\"alphanumeric\"` (the long sign-in code and backup codes), which 4.31.0 adds; older 4.x drops the letters. RAISED TO ^4.35.0: every password field passes `revealable` to `TextFieldInput` (the eye button, 4.35.0); older 4.x drops the prop silently and the password cannot be shown. The provider also mounts `@oxy.so/bloom/locale` (4.32.0+). RAISED TO ^5.1.0 (services 9.0.0, a major because consumers must move to Bloom 5 with it): Bloom 5.0.0 types `Card`'s `style` without radius, border width or `overflow` (geometry goes through `radius`/`border`/`clipContent`) and stops clipping a Card's children by default, so `BenefitList`'s `overflow: 'visible'` became both a type error and unnecessary; it is removed. The cap MOVES rather than widening to a `^4 || ^5` union, for the reason the 3.0.1 note records: a union lets an app resolve a second, nested Bloom copy and split the React contexts. The floor is 5.1.0 because that is the version measured here (every package builds and tests against it); 5.0.0 was not measured.",
|
|
216
216
|
"peerDependencies": {
|
|
217
217
|
"@expo/vector-icons": "^15.0.3",
|
|
218
|
-
"@oxy.so/bloom": "^
|
|
218
|
+
"@oxy.so/bloom": "^5.1.0",
|
|
219
219
|
"@oxy.so/core": "^3.2.0",
|
|
220
220
|
"@react-native-async-storage/async-storage": "^2.0.0",
|
|
221
221
|
"@react-native-community/netinfo": ">=11.4.1",
|
|
@@ -103,7 +103,7 @@ export const BenefitList: React.FC<BenefitListProps> = ({
|
|
|
103
103
|
border="hairline"
|
|
104
104
|
elevation="s"
|
|
105
105
|
className={className}
|
|
106
|
-
style={[{ padding: SPACING['space-16'], gap: SPACING['space-16']
|
|
106
|
+
style={[{ padding: SPACING['space-16'], gap: SPACING['space-16'] }, style]}
|
|
107
107
|
accessibilityLabel={accessibilityLabel}
|
|
108
108
|
>
|
|
109
109
|
{children}
|
|
@@ -105,7 +105,7 @@ const OxyProvider: FC<OxyProviderProps> = ({
|
|
|
105
105
|
const persistenceUnsubRef = useRef<(() => void) | null>(null);
|
|
106
106
|
const ownsQueryClientRef = useRef(providedQueryClient === undefined);
|
|
107
107
|
// `accountQueries` is read once, at mount, like the client itself.
|
|
108
|
-
const
|
|
108
|
+
const accountQueriesRef = useRef(accountQueries);
|
|
109
109
|
const [platformStorage, setPlatformStorage] = useState<StorageInterface | null>(null);
|
|
110
110
|
|
|
111
111
|
// If the consumer supplied their own QueryClient we use it as-is and skip
|
|
@@ -139,7 +139,7 @@ const OxyProvider: FC<OxyProviderProps> = ({
|
|
|
139
139
|
setPlatformStorage(storage);
|
|
140
140
|
const client = queryClientRef.current;
|
|
141
141
|
if (!client || !ownsQueryClientRef.current) return;
|
|
142
|
-
const persistence = attachQueryPersistence(client, storage,
|
|
142
|
+
const persistence = attachQueryPersistence(client, storage, accountQueriesRef.current);
|
|
143
143
|
persistenceUnsubRef.current = persistence.unsubscribe;
|
|
144
144
|
await persistence.restored;
|
|
145
145
|
};
|
|
@@ -300,6 +300,10 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
|
|
|
300
300
|
: null;
|
|
301
301
|
}
|
|
302
302
|
const accountQueryPersistence = accountQueryPersistenceRef.current;
|
|
303
|
+
useEffect(() => {
|
|
304
|
+
accountQueryPersistence?.attach();
|
|
305
|
+
return () => accountQueryPersistence?.detach();
|
|
306
|
+
}, [accountQueryPersistence]);
|
|
303
307
|
const isAccountCacheReady = useSyncExternalStore(
|
|
304
308
|
accountQueryPersistence ? accountQueryPersistence.subscribe : subscribeNever,
|
|
305
309
|
() => accountQueryPersistence?.isReady() ?? true,
|
|
@@ -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
|
-
* -
|
|
12
|
-
*
|
|
13
|
-
* - On a subject change the
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
*
|
|
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 {
|
|
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
|
|
72
|
-
const
|
|
73
|
-
|
|
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
|
|
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
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
170
|
+
const restore = () => {
|
|
121
171
|
const id = accountId;
|
|
122
|
-
|
|
123
|
-
if (!id || !target || unsubscribePersist) return;
|
|
172
|
+
if (!attached || id === null) return;
|
|
124
173
|
const current = generation;
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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 (
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
222
|
+
attach() {
|
|
223
|
+
if (attached) return;
|
|
224
|
+
attached = true;
|
|
225
|
+
restore();
|
|
226
|
+
},
|
|
172
227
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
+
}
|