@oxy.so/services 9.0.0 → 9.2.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/index.js +4 -3
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/ui/client.js +174 -0
- package/lib/commonjs/ui/client.js.map +1 -1
- package/lib/commonjs/ui/components/FollowTargetButton.js +11 -101
- package/lib/commonjs/ui/components/FollowTargetButton.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/components/followRules.js +105 -0
- package/lib/commonjs/ui/components/followRules.js.map +1 -0
- 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/index.js +3 -2
- package/lib/module/index.js.map +1 -1
- package/lib/module/ui/client.js +17 -4
- package/lib/module/ui/client.js.map +1 -1
- package/lib/module/ui/components/FollowTargetButton.js +1 -89
- package/lib/module/ui/components/FollowTargetButton.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/components/followRules.js +99 -0
- package/lib/module/ui/components/followRules.js.map +1 -0
- 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/index.d.ts +4 -2
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/client.d.ts +19 -4
- package/lib/typescript/commonjs/ui/client.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/FollowTargetButton.d.ts +2 -62
- package/lib/typescript/commonjs/ui/components/FollowTargetButton.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/followRules.d.ts +71 -0
- package/lib/typescript/commonjs/ui/components/followRules.d.ts.map +1 -0
- 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/index.d.ts +4 -2
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/ui/client.d.ts +19 -4
- package/lib/typescript/module/ui/client.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/FollowTargetButton.d.ts +2 -62
- package/lib/typescript/module/ui/components/FollowTargetButton.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/followRules.d.ts +71 -0
- package/lib/typescript/module/ui/components/followRules.d.ts.map +1 -0
- 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 +1 -1
- package/src/index.ts +3 -3
- package/src/ui/client.ts +55 -4
- package/src/ui/components/FollowTargetButton.tsx +8 -112
- package/src/ui/components/OxyProvider.tsx +2 -2
- package/src/ui/components/__tests__/followTargetButton.test.ts +1 -1
- package/src/ui/components/followRules.ts +121 -0
- 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
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
import { memo, useCallback, useMemo } from 'react';
|
|
35
|
-
import type {
|
|
35
|
+
import type { FollowStatus } from '@oxy.so/contracts';
|
|
36
36
|
import type { StyleProp, ViewStyle } from 'react-native';
|
|
37
37
|
import { View } from 'react-native';
|
|
38
38
|
import { FollowButton as BloomFollowButton } from '@oxy.so/bloom/media-header';
|
|
@@ -51,6 +51,13 @@ import {
|
|
|
51
51
|
import { toast } from '@oxy.so/bloom/toast';
|
|
52
52
|
import { useFollowTarget } from '../hooks/useFollowTarget';
|
|
53
53
|
import { useFollowTargetStore } from '../stores/followTargetStore';
|
|
54
|
+
import {
|
|
55
|
+
buildFollowMenuItems,
|
|
56
|
+
FOLLOW_ACTION_LEAVES_ACTIVE,
|
|
57
|
+
resolveFollowPrimaryAction,
|
|
58
|
+
type FollowDuration,
|
|
59
|
+
type FollowMenuItem,
|
|
60
|
+
} from './followRules';
|
|
54
61
|
|
|
55
62
|
/**
|
|
56
63
|
* The small vocabulary of verbs the ecosystem actually uses. An application
|
|
@@ -82,12 +89,6 @@ const VERB_LABELS: Record<FollowVerb, FollowLabels> = {
|
|
|
82
89
|
watch: { idle: 'Watch', active: 'Watching', pending: 'Requested', disabled: 'Off here' },
|
|
83
90
|
};
|
|
84
91
|
|
|
85
|
-
/** A timed-follow choice offered in the menu. */
|
|
86
|
-
export interface FollowDuration {
|
|
87
|
-
label: string;
|
|
88
|
-
seconds: number;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
92
|
const HOUR = 60 * 60;
|
|
92
93
|
|
|
93
94
|
/**
|
|
@@ -101,111 +102,6 @@ const DEFAULT_DURATIONS: FollowDuration[] = [
|
|
|
101
102
|
{ label: 'A week', seconds: 7 * 24 * HOUR },
|
|
102
103
|
];
|
|
103
104
|
|
|
104
|
-
/**
|
|
105
|
-
* Whether an action leaves the target ACTIVE in this application.
|
|
106
|
-
*
|
|
107
|
-
* One table, read by both the primary button and the menu, because the same
|
|
108
|
-
* action reached from two controls once reported differently — and a rule
|
|
109
|
-
* living in two switch statements is a rule that drifts. `Record` rather than
|
|
110
|
-
* a function with a default, so a new action is a compile error instead of a
|
|
111
|
-
* silent `false`.
|
|
112
|
-
*/
|
|
113
|
-
export const FOLLOW_ACTION_LEAVES_ACTIVE: Record<
|
|
114
|
-
'follow' | 'follow-timed' | 'enable-here' | 'disable-here' | 'unfollow',
|
|
115
|
-
boolean
|
|
116
|
-
> = {
|
|
117
|
-
follow: true,
|
|
118
|
-
'follow-timed': true,
|
|
119
|
-
// Re-enabling here does not change the global follow — the user already had
|
|
120
|
-
// it — but it does change whether this application acts on it, which is what
|
|
121
|
-
// a mirror is asking about.
|
|
122
|
-
'enable-here': true,
|
|
123
|
-
'disable-here': false,
|
|
124
|
-
unfollow: false,
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
/** One line in the disclosure menu. */
|
|
128
|
-
export interface FollowMenuItem {
|
|
129
|
-
key: string;
|
|
130
|
-
label: string;
|
|
131
|
-
/** What the component should call. Named so the table below stays pure. */
|
|
132
|
-
action:
|
|
133
|
-
| { type: 'follow-timed'; seconds: number; durationLabel: string }
|
|
134
|
-
| { type: 'enable-here' }
|
|
135
|
-
| { type: 'disable-here' }
|
|
136
|
-
| { type: 'unfollow' };
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Which choices exist, given the state.
|
|
141
|
-
*
|
|
142
|
-
* Pure and exported because this is the product decision, not a rendering
|
|
143
|
-
* detail: a timed follow is only offered before following, turning it off here
|
|
144
|
-
* is only offered while following, and NEITHER is offered before the server has
|
|
145
|
-
* answered — every one of them addresses a relationship that does not exist
|
|
146
|
-
* yet, so offering them mid-write would mean sending a guessed id.
|
|
147
|
-
*/
|
|
148
|
-
/**
|
|
149
|
-
* What the main button should do for the current state.
|
|
150
|
-
*
|
|
151
|
-
* Exported because the product rule is not obvious from the label alone: a
|
|
152
|
-
* follow switched off here still reads as "following" globally, so the primary
|
|
153
|
-
* press must re-enable here — not unfollow everywhere.
|
|
154
|
-
*/
|
|
155
|
-
export function resolveFollowPrimaryAction(input: {
|
|
156
|
-
isFollowing: boolean;
|
|
157
|
-
applicationMode: FollowApplicationMode;
|
|
158
|
-
}): 'follow' | 'unfollow' | 'enable-here' {
|
|
159
|
-
if (!input.isFollowing) return 'follow';
|
|
160
|
-
if (input.applicationMode === 'disabled') return 'enable-here';
|
|
161
|
-
return 'unfollow';
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
export function buildFollowMenuItems(input: {
|
|
165
|
-
following: boolean;
|
|
166
|
-
applicationMode: FollowApplicationMode;
|
|
167
|
-
hasRelationship: boolean;
|
|
168
|
-
isPending: boolean;
|
|
169
|
-
durations: FollowDuration[] | false;
|
|
170
|
-
idleVerb: string;
|
|
171
|
-
applicationName: string;
|
|
172
|
-
}): FollowMenuItem[] {
|
|
173
|
-
const items: FollowMenuItem[] = [];
|
|
174
|
-
|
|
175
|
-
if (!input.following) {
|
|
176
|
-
if (input.durations === false) return items;
|
|
177
|
-
for (const d of input.durations) {
|
|
178
|
-
items.push({
|
|
179
|
-
key: `for-${d.seconds}`,
|
|
180
|
-
label: `${input.idleVerb} for ${d.label.toLowerCase()}`,
|
|
181
|
-
action: { type: 'follow-timed', seconds: d.seconds, durationLabel: d.label },
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
|
-
return items;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
if (!input.hasRelationship || input.isPending) return items;
|
|
188
|
-
|
|
189
|
-
items.push(
|
|
190
|
-
input.applicationMode === 'disabled'
|
|
191
|
-
? { key: 'enable-here', label: `Show in ${input.applicationName}`, action: { type: 'enable-here' } }
|
|
192
|
-
: {
|
|
193
|
-
key: 'disable-here',
|
|
194
|
-
label: `Don’t show in ${input.applicationName}`,
|
|
195
|
-
action: { type: 'disable-here' },
|
|
196
|
-
}
|
|
197
|
-
);
|
|
198
|
-
items.push({
|
|
199
|
-
// Named for what it does. "Unfollow" beside "don't show here" would read as
|
|
200
|
-
// the same action twice, and the user would pick the wrong one.
|
|
201
|
-
key: 'unfollow-everywhere',
|
|
202
|
-
label: 'Unfollow everywhere',
|
|
203
|
-
action: { type: 'unfollow' },
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
return items;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
105
|
export interface FollowTargetButtonProps {
|
|
210
106
|
/** The registered target's id. Registration is separate — see the SDK. */
|
|
211
107
|
targetId: string;
|
|
@@ -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
|
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The follow button's product rules, without the button.
|
|
3
|
+
*
|
|
4
|
+
* Pure: no React, no Bloom. `FollowTargetButton` renders them, and an
|
|
5
|
+
* application drawing its own affordance (a chip grid, a compact row) imports
|
|
6
|
+
* them from here — through `@oxy.so/services/ui/client` — without paying for the
|
|
7
|
+
* button's dropdown menu.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { FollowApplicationMode } from '@oxy.so/contracts';
|
|
11
|
+
|
|
12
|
+
/** A timed-follow choice offered in the menu. */
|
|
13
|
+
export interface FollowDuration {
|
|
14
|
+
label: string;
|
|
15
|
+
seconds: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Whether an action leaves the target ACTIVE in this application.
|
|
20
|
+
*
|
|
21
|
+
* One table, read by both the primary button and the menu, because the same
|
|
22
|
+
* action reached from two controls once reported differently — and a rule
|
|
23
|
+
* living in two switch statements is a rule that drifts. `Record` rather than
|
|
24
|
+
* a function with a default, so a new action is a compile error instead of a
|
|
25
|
+
* silent `false`.
|
|
26
|
+
*/
|
|
27
|
+
export const FOLLOW_ACTION_LEAVES_ACTIVE: Record<
|
|
28
|
+
'follow' | 'follow-timed' | 'enable-here' | 'disable-here' | 'unfollow',
|
|
29
|
+
boolean
|
|
30
|
+
> = {
|
|
31
|
+
follow: true,
|
|
32
|
+
'follow-timed': true,
|
|
33
|
+
// Re-enabling here does not change the global follow — the user already had
|
|
34
|
+
// it — but it does change whether this application acts on it, which is what
|
|
35
|
+
// a mirror is asking about.
|
|
36
|
+
'enable-here': true,
|
|
37
|
+
'disable-here': false,
|
|
38
|
+
unfollow: false,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** One line in the disclosure menu. */
|
|
42
|
+
export interface FollowMenuItem {
|
|
43
|
+
key: string;
|
|
44
|
+
label: string;
|
|
45
|
+
/** What the component should call. Named so the table below stays pure. */
|
|
46
|
+
action:
|
|
47
|
+
| { type: 'follow-timed'; seconds: number; durationLabel: string }
|
|
48
|
+
| { type: 'enable-here' }
|
|
49
|
+
| { type: 'disable-here' }
|
|
50
|
+
| { type: 'unfollow' };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Which choices exist, given the state.
|
|
55
|
+
*
|
|
56
|
+
* Pure and exported because this is the product decision, not a rendering
|
|
57
|
+
* detail: a timed follow is only offered before following, turning it off here
|
|
58
|
+
* is only offered while following, and NEITHER is offered before the server has
|
|
59
|
+
* answered — every one of them addresses a relationship that does not exist
|
|
60
|
+
* yet, so offering them mid-write would mean sending a guessed id.
|
|
61
|
+
*/
|
|
62
|
+
/**
|
|
63
|
+
* What the main button should do for the current state.
|
|
64
|
+
*
|
|
65
|
+
* Exported because the product rule is not obvious from the label alone: a
|
|
66
|
+
* follow switched off here still reads as "following" globally, so the primary
|
|
67
|
+
* press must re-enable here — not unfollow everywhere.
|
|
68
|
+
*/
|
|
69
|
+
export function resolveFollowPrimaryAction(input: {
|
|
70
|
+
isFollowing: boolean;
|
|
71
|
+
applicationMode: FollowApplicationMode;
|
|
72
|
+
}): 'follow' | 'unfollow' | 'enable-here' {
|
|
73
|
+
if (!input.isFollowing) return 'follow';
|
|
74
|
+
if (input.applicationMode === 'disabled') return 'enable-here';
|
|
75
|
+
return 'unfollow';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function buildFollowMenuItems(input: {
|
|
79
|
+
following: boolean;
|
|
80
|
+
applicationMode: FollowApplicationMode;
|
|
81
|
+
hasRelationship: boolean;
|
|
82
|
+
isPending: boolean;
|
|
83
|
+
durations: FollowDuration[] | false;
|
|
84
|
+
idleVerb: string;
|
|
85
|
+
applicationName: string;
|
|
86
|
+
}): FollowMenuItem[] {
|
|
87
|
+
const items: FollowMenuItem[] = [];
|
|
88
|
+
|
|
89
|
+
if (!input.following) {
|
|
90
|
+
if (input.durations === false) return items;
|
|
91
|
+
for (const d of input.durations) {
|
|
92
|
+
items.push({
|
|
93
|
+
key: `for-${d.seconds}`,
|
|
94
|
+
label: `${input.idleVerb} for ${d.label.toLowerCase()}`,
|
|
95
|
+
action: { type: 'follow-timed', seconds: d.seconds, durationLabel: d.label },
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return items;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!input.hasRelationship || input.isPending) return items;
|
|
102
|
+
|
|
103
|
+
items.push(
|
|
104
|
+
input.applicationMode === 'disabled'
|
|
105
|
+
? { key: 'enable-here', label: `Show in ${input.applicationName}`, action: { type: 'enable-here' } }
|
|
106
|
+
: {
|
|
107
|
+
key: 'disable-here',
|
|
108
|
+
label: `Don’t show in ${input.applicationName}`,
|
|
109
|
+
action: { type: 'disable-here' },
|
|
110
|
+
}
|
|
111
|
+
);
|
|
112
|
+
items.push({
|
|
113
|
+
// Named for what it does. "Unfollow" beside "don't show here" would read as
|
|
114
|
+
// the same action twice, and the user would pick the wrong one.
|
|
115
|
+
key: 'unfollow-everywhere',
|
|
116
|
+
label: 'Unfollow everywhere',
|
|
117
|
+
action: { type: 'unfollow' },
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return items;
|
|
121
|
+
}
|
|
@@ -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
|
}
|