@terreno/rtk 0.13.0 → 0.14.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/dist/authSlice.test.js +2 -10
- package/dist/authSlice.test.js.map +1 -1
- package/dist/authSliceNative.test.js +11 -3
- package/dist/authSliceNative.test.js.map +1 -1
- package/dist/betterAuthSlice.test.js +1 -25
- package/dist/betterAuthSlice.test.js.map +1 -1
- package/dist/buildNumber.test.js +19 -0
- package/dist/buildNumber.test.js.map +1 -1
- package/dist/constants.d.ts +3 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +11 -3
- package/dist/constants.js.map +1 -1
- package/dist/constants.test.js +44 -2
- package/dist/constants.test.js.map +1 -1
- package/dist/emptyApi.d.ts +2 -0
- package/dist/emptyApi.d.ts.map +1 -1
- package/dist/emptyApi.js +5 -0
- package/dist/emptyApi.js.map +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/isolated/constants.isolated.js +7 -1
- package/dist/isolated/constants.isolated.js.map +1 -1
- package/dist/isolated/offlineMiddleware.isolated.d.ts +2 -0
- package/dist/isolated/offlineMiddleware.isolated.d.ts.map +1 -0
- package/dist/isolated/offlineMiddleware.isolated.js +516 -0
- package/dist/isolated/offlineMiddleware.isolated.js.map +1 -0
- package/dist/isolated/offlineMiddleware.native.isolated.d.ts +2 -0
- package/dist/isolated/offlineMiddleware.native.isolated.d.ts.map +1 -0
- package/dist/isolated/offlineMiddleware.native.isolated.js +70 -0
- package/dist/isolated/offlineMiddleware.native.isolated.js.map +1 -0
- package/dist/isolated/useUpgradeCheck.mobile.isolated.d.ts +2 -0
- package/dist/isolated/useUpgradeCheck.mobile.isolated.d.ts.map +1 -0
- package/dist/{useUpgradeCheck.mobile.test.js → isolated/useUpgradeCheck.mobile.isolated.js} +13 -5
- package/dist/isolated/useUpgradeCheck.mobile.isolated.js.map +1 -0
- package/dist/isolated/useUpgradeCheck.web.isolated.d.ts +2 -0
- package/dist/isolated/useUpgradeCheck.web.isolated.d.ts.map +1 -0
- package/dist/{useUpgradeCheck.test.js → isolated/useUpgradeCheck.web.isolated.js} +37 -7
- package/dist/isolated/useUpgradeCheck.web.isolated.js.map +1 -0
- package/dist/mongooseSlice.test.js +1 -1
- package/dist/mongooseSlice.test.js.map +1 -1
- package/dist/offlineGate.d.ts +8 -0
- package/dist/offlineGate.d.ts.map +1 -0
- package/dist/offlineGate.js +20 -0
- package/dist/offlineGate.js.map +1 -0
- package/dist/offlineGate.test.d.ts +2 -0
- package/dist/offlineGate.test.d.ts.map +1 -0
- package/dist/offlineGate.test.js +46 -0
- package/dist/offlineGate.test.js.map +1 -0
- package/dist/offlineMiddleware.d.ts +46 -0
- package/dist/offlineMiddleware.d.ts.map +1 -0
- package/dist/offlineMiddleware.js +591 -0
- package/dist/offlineMiddleware.js.map +1 -0
- package/dist/offlineMiddleware.test.d.ts +2 -0
- package/dist/offlineMiddleware.test.d.ts.map +1 -0
- package/dist/offlineMiddleware.test.js +145 -0
- package/dist/offlineMiddleware.test.js.map +1 -0
- package/dist/offlineSlice.d.ts +232 -0
- package/dist/offlineSlice.d.ts.map +1 -0
- package/dist/offlineSlice.js +65 -0
- package/dist/offlineSlice.js.map +1 -0
- package/dist/offlineSlice.test.d.ts +2 -0
- package/dist/offlineSlice.test.d.ts.map +1 -0
- package/dist/offlineSlice.test.js +153 -0
- package/dist/offlineSlice.test.js.map +1 -0
- package/dist/realtime.d.ts +123 -0
- package/dist/realtime.d.ts.map +1 -0
- package/dist/realtime.js +299 -0
- package/dist/realtime.js.map +1 -0
- package/dist/realtime.test.d.ts +2 -0
- package/dist/realtime.test.d.ts.map +1 -0
- package/dist/realtime.test.js +339 -0
- package/dist/realtime.test.js.map +1 -0
- package/dist/socket.d.ts.map +1 -1
- package/dist/socket.js +29 -28
- package/dist/socket.js.map +1 -1
- package/dist/sync.d.ts +35 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +205 -0
- package/dist/sync.js.map +1 -0
- package/dist/tagGenerator.d.ts +20 -1
- package/dist/tagGenerator.d.ts.map +1 -1
- package/dist/tagGenerator.js +1 -2
- package/dist/tagGenerator.js.map +1 -1
- package/dist/useOfflineStatus.d.ts +33 -0
- package/dist/useOfflineStatus.d.ts.map +1 -0
- package/dist/useOfflineStatus.js +43 -0
- package/dist/useOfflineStatus.js.map +1 -0
- package/dist/useOfflineStatus.test.d.ts +2 -0
- package/dist/useOfflineStatus.test.d.ts.map +1 -0
- package/dist/useOfflineStatus.test.js +119 -0
- package/dist/useOfflineStatus.test.js.map +1 -0
- package/dist/useRealtimeDebug.d.ts +6 -0
- package/dist/useRealtimeDebug.d.ts.map +1 -0
- package/dist/useRealtimeDebug.js +41 -0
- package/dist/useRealtimeDebug.js.map +1 -0
- package/dist/useServerStatus.d.ts +29 -0
- package/dist/useServerStatus.d.ts.map +1 -0
- package/dist/useServerStatus.js +89 -0
- package/dist/useServerStatus.js.map +1 -0
- package/dist/useUpgradeCheck.d.ts +7 -3
- package/dist/useUpgradeCheck.d.ts.map +1 -1
- package/dist/useUpgradeCheck.js +27 -13
- package/dist/useUpgradeCheck.js.map +1 -1
- package/package.json +2 -2
- package/src/authSlice.test.ts +2 -11
- package/src/authSliceNative.test.ts +11 -4
- package/src/betterAuthSlice.test.ts +1 -25
- package/src/buildNumber.test.ts +19 -0
- package/src/constants.test.ts +54 -1
- package/src/constants.ts +14 -3
- package/src/emptyApi.ts +8 -0
- package/src/index.ts +8 -0
- package/src/isolated/constants.isolated.ts +7 -3
- package/src/isolated/offlineMiddleware.isolated.ts +696 -0
- package/src/isolated/offlineMiddleware.native.isolated.ts +80 -0
- package/src/{useUpgradeCheck.mobile.test.ts → isolated/useUpgradeCheck.mobile.isolated.ts} +13 -4
- package/src/{useUpgradeCheck.test.ts → isolated/useUpgradeCheck.web.isolated.ts} +48 -6
- package/src/mongooseSlice.test.ts +1 -1
- package/src/offlineGate.test.ts +55 -0
- package/src/offlineGate.ts +25 -0
- package/src/offlineMiddleware.test.ts +187 -0
- package/src/offlineMiddleware.ts +718 -0
- package/src/offlineSlice.test.ts +204 -0
- package/src/offlineSlice.ts +130 -0
- package/src/realtime.test.ts +404 -0
- package/src/realtime.ts +408 -0
- package/src/socket.ts +33 -32
- package/src/sync.ts +310 -0
- package/src/tagGenerator.ts +29 -11
- package/src/useOfflineStatus.test.ts +157 -0
- package/src/useOfflineStatus.ts +82 -0
- package/src/useRealtimeDebug.ts +51 -0
- package/src/useServerStatus.ts +122 -0
- package/src/useUpgradeCheck.ts +35 -13
- package/dist/isolated/useUpgradeCheck.isolated.d.ts +0 -2
- package/dist/isolated/useUpgradeCheck.isolated.d.ts.map +0 -1
- package/dist/isolated/useUpgradeCheck.isolated.js +0 -153
- package/dist/isolated/useUpgradeCheck.isolated.js.map +0 -1
- package/dist/useUpgradeCheck.mobile.test.d.ts +0 -2
- package/dist/useUpgradeCheck.mobile.test.d.ts.map +0 -1
- package/dist/useUpgradeCheck.mobile.test.js.map +0 -1
- package/dist/useUpgradeCheck.test.d.ts +0 -2
- package/dist/useUpgradeCheck.test.d.ts.map +0 -1
- package/dist/useUpgradeCheck.test.js.map +0 -1
- package/src/isolated/useUpgradeCheck.isolated.ts +0 -201
package/src/sync.ts
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import {DateTime} from "luxon";
|
|
2
|
+
import {useEffect, useRef} from "react";
|
|
3
|
+
import {useDispatch, useStore} from "react-redux";
|
|
4
|
+
import type {Socket} from "socket.io-client";
|
|
5
|
+
|
|
6
|
+
import {isWebsocketsDebugEnabled, logSocket} from "./constants";
|
|
7
|
+
import type {RealtimeEvent} from "./realtime";
|
|
8
|
+
|
|
9
|
+
interface DocumentItem {
|
|
10
|
+
_id?: string;
|
|
11
|
+
id?: string;
|
|
12
|
+
updated?: string;
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface CacheEntryData {
|
|
17
|
+
data?: DocumentItem[];
|
|
18
|
+
_id?: string;
|
|
19
|
+
id?: string;
|
|
20
|
+
updated?: string;
|
|
21
|
+
total?: number;
|
|
22
|
+
[key: string]: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface QueryCacheEntry {
|
|
26
|
+
status: string;
|
|
27
|
+
data?: CacheEntryData;
|
|
28
|
+
endpointName: string;
|
|
29
|
+
originalArgs: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface RtkApiState {
|
|
33
|
+
queries?: Record<string, QueryCacheEntry>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// noExplicitAny: RTK Query's Api type requires four generic parameters (BaseQuery, Definitions,
|
|
37
|
+
// ReducerPath, TagTypes) that vary per consumer. A structural interface would lose dispatch
|
|
38
|
+
// compatibility for thunk-returning util methods like updateQueryData.
|
|
39
|
+
interface UseSyncConnectionOptions {
|
|
40
|
+
/** Socket.io client instance (from useSocketConnection) */
|
|
41
|
+
socket: Socket | null;
|
|
42
|
+
/** RTK Query API instance (the enhanced API with tag types) */
|
|
43
|
+
// biome-ignore lint/suspicious/noExplicitAny: noExplicitAny: RTK Query Api generic requires four app-specific type parameters
|
|
44
|
+
api: any;
|
|
45
|
+
/** Tag types to listen for (e.g. ["todos", "users"]) — these should match the collection field in events */
|
|
46
|
+
tagTypes: string[];
|
|
47
|
+
/** Enable debug logging */
|
|
48
|
+
debug?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Hook that connects WebSocket sync events to RTK Query's cache.
|
|
53
|
+
*
|
|
54
|
+
* For **create** events: invalidates cache tags to trigger refetch of list queries.
|
|
55
|
+
* For **update** events: patches entities in-place in cached queries, falling back to tag invalidation.
|
|
56
|
+
* For **delete** events: removes entities from cached list queries, falling back to tag invalidation.
|
|
57
|
+
*
|
|
58
|
+
* Automatically subscribes to model rooms for each tagType when the socket connects,
|
|
59
|
+
* so models using `roomStrategy: "model"` will work without additional setup.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```typescript
|
|
63
|
+
* const { socket } = useSocketConnection({ ... });
|
|
64
|
+
*
|
|
65
|
+
* useSyncConnection({
|
|
66
|
+
* socket,
|
|
67
|
+
* api: terrenoApi,
|
|
68
|
+
* tagTypes: ['todos', 'users'],
|
|
69
|
+
* });
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
export const useSyncConnection = ({
|
|
73
|
+
socket,
|
|
74
|
+
api,
|
|
75
|
+
tagTypes,
|
|
76
|
+
debug = false,
|
|
77
|
+
}: UseSyncConnectionOptions): void => {
|
|
78
|
+
const dispatch = useDispatch();
|
|
79
|
+
const store = useStore();
|
|
80
|
+
const tagTypesRef = useRef(tagTypes);
|
|
81
|
+
const apiRef = useRef(api);
|
|
82
|
+
tagTypesRef.current = tagTypes;
|
|
83
|
+
apiRef.current = api;
|
|
84
|
+
|
|
85
|
+
// Auto-subscribe to model rooms for each tagType when socket connects
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
if (!socket) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const subscribeToModels = (): void => {
|
|
92
|
+
for (const tag of tagTypesRef.current) {
|
|
93
|
+
socket.emit("subscribe:model", tag);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// Subscribe on connect and when already connected
|
|
98
|
+
if (socket.connected) {
|
|
99
|
+
subscribeToModels();
|
|
100
|
+
}
|
|
101
|
+
socket.on("connect", subscribeToModels);
|
|
102
|
+
|
|
103
|
+
return (): void => {
|
|
104
|
+
socket.off("connect", subscribeToModels);
|
|
105
|
+
// Unsubscribe from model rooms on cleanup
|
|
106
|
+
if (socket.connected) {
|
|
107
|
+
for (const tag of tagTypesRef.current) {
|
|
108
|
+
socket.emit("unsubscribe:model", tag);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}, [socket]);
|
|
113
|
+
|
|
114
|
+
// Listen for sync events and update RTK Query cache
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (!socket) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const log = (message: string): void => {
|
|
121
|
+
if (isDebug) {
|
|
122
|
+
logSocket(true, message);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const logEvent = (event: RealtimeEvent): void => {
|
|
127
|
+
if (!isDebug) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
logSocket(true, `sync event: ${JSON.stringify(event)}`);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const isDebug = debug ?? isWebsocketsDebugEnabled();
|
|
134
|
+
|
|
135
|
+
const getApiQueries = (): Record<string, QueryCacheEntry> | null => {
|
|
136
|
+
try {
|
|
137
|
+
const state = store.getState() as Record<string, RtkApiState>;
|
|
138
|
+
return state?.[apiRef.current.reducerPath]?.queries ?? null;
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const handleSync = (event: RealtimeEvent): void => {
|
|
145
|
+
const {collection, method, id, data} = event;
|
|
146
|
+
const currentApi = apiRef.current;
|
|
147
|
+
|
|
148
|
+
// Only process events for collections we care about
|
|
149
|
+
if (!tagTypesRef.current.includes(collection)) {
|
|
150
|
+
log(`Ignoring event for collection: ${collection}`);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
log(`Received ${method} event for ${collection}/${id}`);
|
|
155
|
+
logEvent(event);
|
|
156
|
+
|
|
157
|
+
switch (method) {
|
|
158
|
+
case "update": {
|
|
159
|
+
if (!data) {
|
|
160
|
+
dispatch(currentApi.util.invalidateTags([collection]));
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const queries = getApiQueries();
|
|
165
|
+
if (!queries) {
|
|
166
|
+
dispatch(currentApi.util.invalidateTags([collection]));
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let patched = false;
|
|
171
|
+
for (const [_cacheKey, queryEntry] of Object.entries(queries)) {
|
|
172
|
+
const entry = queryEntry as QueryCacheEntry;
|
|
173
|
+
if (entry?.status !== "fulfilled" || !entry?.data) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const {endpointName, originalArgs} = entry;
|
|
178
|
+
|
|
179
|
+
// List response: {data: [...], total, page, more}
|
|
180
|
+
if (entry.data?.data && Array.isArray(entry.data.data)) {
|
|
181
|
+
const hasEntity = entry.data.data.some(
|
|
182
|
+
(item: DocumentItem) => item._id === id || item.id === id
|
|
183
|
+
);
|
|
184
|
+
if (hasEntity) {
|
|
185
|
+
dispatch(
|
|
186
|
+
currentApi.util.updateQueryData(
|
|
187
|
+
endpointName,
|
|
188
|
+
originalArgs,
|
|
189
|
+
(draft: CacheEntryData) => {
|
|
190
|
+
if (draft?.data && Array.isArray(draft.data)) {
|
|
191
|
+
const index = draft.data.findIndex(
|
|
192
|
+
(item: DocumentItem) => item._id === id || item.id === id
|
|
193
|
+
);
|
|
194
|
+
if (index !== -1) {
|
|
195
|
+
// Stale event check via updated timestamp
|
|
196
|
+
const cachedUpdated = draft.data[index].updated;
|
|
197
|
+
if (
|
|
198
|
+
cachedUpdated &&
|
|
199
|
+
data.updated &&
|
|
200
|
+
DateTime.fromISO(cachedUpdated) > DateTime.fromISO(data.updated)
|
|
201
|
+
) {
|
|
202
|
+
log(`Skipping stale update for ${collection}/${id}`);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
Object.assign(draft.data[index], data);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
)
|
|
210
|
+
);
|
|
211
|
+
patched = true;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// Single entity response: {_id, ...fields}
|
|
215
|
+
else if (entry.data?._id === id || entry.data?.id === id) {
|
|
216
|
+
const cachedUpdated = entry.data.updated;
|
|
217
|
+
if (
|
|
218
|
+
cachedUpdated &&
|
|
219
|
+
data.updated &&
|
|
220
|
+
DateTime.fromISO(cachedUpdated) > DateTime.fromISO(data.updated)
|
|
221
|
+
) {
|
|
222
|
+
log(`Skipping stale update for ${collection}/${id}`);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
dispatch(
|
|
226
|
+
currentApi.util.updateQueryData(
|
|
227
|
+
endpointName,
|
|
228
|
+
originalArgs,
|
|
229
|
+
(draft: CacheEntryData) => {
|
|
230
|
+
Object.assign(draft, data);
|
|
231
|
+
}
|
|
232
|
+
)
|
|
233
|
+
);
|
|
234
|
+
patched = true;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (!patched) {
|
|
239
|
+
dispatch(currentApi.util.invalidateTags([collection]));
|
|
240
|
+
}
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
case "delete": {
|
|
245
|
+
const queries = getApiQueries();
|
|
246
|
+
if (!queries) {
|
|
247
|
+
dispatch(currentApi.util.invalidateTags([collection]));
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
let patched = false;
|
|
252
|
+
for (const [_cacheKey, queryEntry] of Object.entries(queries)) {
|
|
253
|
+
const entry = queryEntry as QueryCacheEntry;
|
|
254
|
+
if (entry?.status !== "fulfilled" || !entry?.data) {
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const {endpointName, originalArgs} = entry;
|
|
259
|
+
|
|
260
|
+
// List response with data array
|
|
261
|
+
if (entry.data?.data && Array.isArray(entry.data.data)) {
|
|
262
|
+
const hasEntity = entry.data.data.some(
|
|
263
|
+
(item: DocumentItem) => item._id === id || item.id === id
|
|
264
|
+
);
|
|
265
|
+
if (hasEntity) {
|
|
266
|
+
dispatch(
|
|
267
|
+
currentApi.util.updateQueryData(
|
|
268
|
+
endpointName,
|
|
269
|
+
originalArgs,
|
|
270
|
+
(draft: CacheEntryData) => {
|
|
271
|
+
if (draft?.data && Array.isArray(draft.data)) {
|
|
272
|
+
draft.data = draft.data.filter(
|
|
273
|
+
(item: DocumentItem) => item._id !== id && item.id !== id
|
|
274
|
+
);
|
|
275
|
+
if (typeof draft.total === "number") {
|
|
276
|
+
draft.total = Math.max(0, draft.total - 1);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
)
|
|
281
|
+
);
|
|
282
|
+
patched = true;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (!patched) {
|
|
288
|
+
dispatch(currentApi.util.invalidateTags([collection]));
|
|
289
|
+
}
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
case "create": {
|
|
294
|
+
// For creates, we can't determine filter match client-side,
|
|
295
|
+
// so invalidate tags to trigger refetch of list queries
|
|
296
|
+
dispatch(currentApi.util.invalidateTags([collection]));
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
socket.on("sync", handleSync);
|
|
303
|
+
log("Listening for sync events");
|
|
304
|
+
|
|
305
|
+
return (): void => {
|
|
306
|
+
socket.off("sync", handleSync);
|
|
307
|
+
log("Stopped listening for sync events");
|
|
308
|
+
};
|
|
309
|
+
}, [socket, dispatch, store, debug]);
|
|
310
|
+
};
|
package/src/tagGenerator.ts
CHANGED
|
@@ -1,23 +1,38 @@
|
|
|
1
|
-
// biome-ignore-all lint/suspicious/noExplicitAny: Generics
|
|
2
|
-
|
|
3
1
|
// use this with enhanceEndpoints since the code generator doesn't invalidate by individual ids,
|
|
4
2
|
// only at the full collection level
|
|
5
3
|
|
|
4
|
+
interface TagProviderResult {
|
|
5
|
+
data?: Array<{_id: string}>;
|
|
6
|
+
_id?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
type TagEntry = string | {type: string; id?: string};
|
|
10
|
+
type TagProviderFn = (result: TagProviderResult | null | undefined) => TagEntry[];
|
|
11
|
+
|
|
12
|
+
interface EndpointTagConfig {
|
|
13
|
+
providesTags: TagProviderFn;
|
|
14
|
+
invalidatesTags: TagProviderFn;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ApiWithEndpoints {
|
|
18
|
+
endpoints: Record<string, unknown>;
|
|
19
|
+
}
|
|
20
|
+
|
|
6
21
|
const providesIdTags =
|
|
7
22
|
(path: string) =>
|
|
8
|
-
(result:
|
|
9
|
-
result ? [...(result?.data?.map(({_id}
|
|
23
|
+
(result: TagProviderResult | null | undefined): TagEntry[] =>
|
|
24
|
+
result ? [...(result?.data?.map(({_id}) => ({id: _id, type: path})) ?? []), path] : [path];
|
|
10
25
|
|
|
11
26
|
const providesIdTag =
|
|
12
27
|
(path: string) =>
|
|
13
|
-
(result:
|
|
28
|
+
(result: TagProviderResult | null | undefined): TagEntry[] => {
|
|
14
29
|
return result ? [{id: result._id, type: path}] : [path];
|
|
15
30
|
};
|
|
16
31
|
|
|
17
32
|
const invalidatesIdTags =
|
|
18
33
|
(path: string) =>
|
|
19
|
-
(result:
|
|
20
|
-
result ? [...(result?.data?.map(({_id}
|
|
34
|
+
(result: TagProviderResult | null | undefined): TagEntry[] =>
|
|
35
|
+
result ? [...(result?.data?.map(({_id}) => ({id: _id, type: path})) ?? []), path] : [path];
|
|
21
36
|
|
|
22
37
|
const cleanEndpointStringToGenerateTag = (string: string): string => {
|
|
23
38
|
// Define the prefixes and suffix
|
|
@@ -33,14 +48,17 @@ const cleanEndpointStringToGenerateTag = (string: string): string => {
|
|
|
33
48
|
return string.replace(regex, "")?.toLowerCase();
|
|
34
49
|
};
|
|
35
50
|
|
|
36
|
-
export const generateTags = (
|
|
51
|
+
export const generateTags = (
|
|
52
|
+
api: ApiWithEndpoints,
|
|
53
|
+
tagTypes: string[]
|
|
54
|
+
): Record<string, EndpointTagConfig> => {
|
|
37
55
|
// take the api, and for each get and list endpoint, generate tags that invalidate the cache by id
|
|
38
56
|
// and by the list endpoint
|
|
39
57
|
const endpoints = api.endpoints;
|
|
40
|
-
const tags:
|
|
58
|
+
const tags: Record<string, Partial<EndpointTagConfig>> = {};
|
|
41
59
|
Object.keys(endpoints).forEach((endpoint) => {
|
|
42
60
|
if (endpoint === "getConversations") {
|
|
43
|
-
tags[endpoint] = {invalidatesTags: ["conversations", "messages"]};
|
|
61
|
+
tags[endpoint] = {invalidatesTags: (): TagEntry[] => ["conversations", "messages"]};
|
|
44
62
|
}
|
|
45
63
|
if (endpoint.toLowerCase().includes("get")) {
|
|
46
64
|
// List endpoints
|
|
@@ -76,5 +94,5 @@ export const generateTags = (api: any, tagTypes: string[]): any => {
|
|
|
76
94
|
}
|
|
77
95
|
}
|
|
78
96
|
});
|
|
79
|
-
return tags
|
|
97
|
+
return tags as Record<string, EndpointTagConfig>;
|
|
80
98
|
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import {beforeEach, describe, expect, it} from "bun:test";
|
|
2
|
+
import {configureStore} from "@reduxjs/toolkit";
|
|
3
|
+
import {act, renderHook} from "@testing-library/react-native";
|
|
4
|
+
import React from "react";
|
|
5
|
+
import {Provider} from "react-redux";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
addConflict,
|
|
9
|
+
type ConflictRecord,
|
|
10
|
+
enqueue,
|
|
11
|
+
offlineReducer,
|
|
12
|
+
type QueuedMutation,
|
|
13
|
+
setOnlineStatus,
|
|
14
|
+
setSyncing,
|
|
15
|
+
} from "./offlineSlice";
|
|
16
|
+
import {useOfflineStatus} from "./useOfflineStatus";
|
|
17
|
+
|
|
18
|
+
const createTestStore = () =>
|
|
19
|
+
configureStore({
|
|
20
|
+
reducer: {offline: offlineReducer},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const createWrapper = (store: ReturnType<typeof createTestStore>) => {
|
|
24
|
+
const Wrapper: React.FC<{children: React.ReactNode}> = ({children}) =>
|
|
25
|
+
React.createElement(Provider, {children, store});
|
|
26
|
+
return Wrapper;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
describe("useOfflineStatus", () => {
|
|
30
|
+
let store: ReturnType<typeof createTestStore>;
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
store = createTestStore();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("returns initial online state", () => {
|
|
37
|
+
const {result} = renderHook(() => useOfflineStatus(), {
|
|
38
|
+
wrapper: createWrapper(store),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(result.current.isOnline).toBe(true);
|
|
42
|
+
expect(result.current.queueLength).toBe(0);
|
|
43
|
+
expect(result.current.isSyncing).toBe(false);
|
|
44
|
+
expect(result.current.conflicts).toEqual([]);
|
|
45
|
+
expect(result.current.undismissedConflicts).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("reflects offline state", () => {
|
|
49
|
+
store.dispatch(setOnlineStatus(false));
|
|
50
|
+
|
|
51
|
+
const {result} = renderHook(() => useOfflineStatus(), {
|
|
52
|
+
wrapper: createWrapper(store),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(result.current.isOnline).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("reports queue length", () => {
|
|
59
|
+
const mutation: QueuedMutation = {
|
|
60
|
+
args: {body: {title: "Test"}},
|
|
61
|
+
endpointName: "postTodos",
|
|
62
|
+
id: "test-1",
|
|
63
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
64
|
+
type: "create",
|
|
65
|
+
};
|
|
66
|
+
store.dispatch(enqueue(mutation));
|
|
67
|
+
|
|
68
|
+
const {result} = renderHook(() => useOfflineStatus(), {
|
|
69
|
+
wrapper: createWrapper(store),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(result.current.queueLength).toBe(1);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("reports syncing state", () => {
|
|
76
|
+
store.dispatch(setSyncing(true));
|
|
77
|
+
|
|
78
|
+
const {result} = renderHook(() => useOfflineStatus(), {
|
|
79
|
+
wrapper: createWrapper(store),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
expect(result.current.isSyncing).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("filters undismissed conflicts", () => {
|
|
86
|
+
const conflict: ConflictRecord = {
|
|
87
|
+
args: {id: "abc"},
|
|
88
|
+
dismissed: false,
|
|
89
|
+
endpointName: "patchTodosById",
|
|
90
|
+
id: "conflict-1",
|
|
91
|
+
serverDocument: {title: "Server"},
|
|
92
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
93
|
+
};
|
|
94
|
+
store.dispatch(addConflict(conflict));
|
|
95
|
+
|
|
96
|
+
const {result} = renderHook(() => useOfflineStatus(), {
|
|
97
|
+
wrapper: createWrapper(store),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(result.current.conflicts).toHaveLength(1);
|
|
101
|
+
expect(result.current.undismissedConflicts).toHaveLength(1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("dismissConflict dispatches the action", () => {
|
|
105
|
+
const conflict: ConflictRecord = {
|
|
106
|
+
args: {id: "abc"},
|
|
107
|
+
dismissed: false,
|
|
108
|
+
endpointName: "patchTodosById",
|
|
109
|
+
id: "conflict-to-dismiss",
|
|
110
|
+
serverDocument: {title: "Server"},
|
|
111
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
112
|
+
};
|
|
113
|
+
store.dispatch(addConflict(conflict));
|
|
114
|
+
|
|
115
|
+
const {result} = renderHook(() => useOfflineStatus(), {
|
|
116
|
+
wrapper: createWrapper(store),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
act(() => {
|
|
120
|
+
result.current.dismissConflict("conflict-to-dismiss");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(result.current.undismissedConflicts).toHaveLength(0);
|
|
124
|
+
expect(result.current.conflicts[0].dismissed).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("clearConflicts removes all conflicts", () => {
|
|
128
|
+
const conflict: ConflictRecord = {
|
|
129
|
+
args: {id: "abc"},
|
|
130
|
+
dismissed: false,
|
|
131
|
+
endpointName: "patchTodosById",
|
|
132
|
+
id: "conflict-clear",
|
|
133
|
+
serverDocument: {title: "Server"},
|
|
134
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
135
|
+
};
|
|
136
|
+
store.dispatch(addConflict(conflict));
|
|
137
|
+
|
|
138
|
+
const {result} = renderHook(() => useOfflineStatus(), {
|
|
139
|
+
wrapper: createWrapper(store),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
act(() => {
|
|
143
|
+
result.current.clearConflicts();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(result.current.conflicts).toHaveLength(0);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("isLocalOnly returns true for temp-prefixed IDs", () => {
|
|
150
|
+
const {result} = renderHook(() => useOfflineStatus(), {
|
|
151
|
+
wrapper: createWrapper(store),
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
expect(result.current.isLocalOnly("temp-abc123")).toBe(true);
|
|
155
|
+
expect(result.current.isLocalOnly("507f1f77bcf86cd799439011")).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import {useCallback} from "react";
|
|
2
|
+
import {useDispatch, useSelector} from "react-redux";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
type ConflictRecord,
|
|
6
|
+
clearConflicts,
|
|
7
|
+
dismissConflict,
|
|
8
|
+
type OfflineState,
|
|
9
|
+
selectConflicts,
|
|
10
|
+
selectIsOnline,
|
|
11
|
+
selectIsSyncing,
|
|
12
|
+
selectQueueLength,
|
|
13
|
+
selectUndismissedConflicts,
|
|
14
|
+
} from "./offlineSlice";
|
|
15
|
+
|
|
16
|
+
export interface OfflineStatus {
|
|
17
|
+
/** Whether the device currently has network connectivity */
|
|
18
|
+
isOnline: boolean;
|
|
19
|
+
/** Number of mutations waiting to be synced */
|
|
20
|
+
queueLength: number;
|
|
21
|
+
/** Whether mutations are currently being replayed to the server */
|
|
22
|
+
isSyncing: boolean;
|
|
23
|
+
/** All conflict records (including dismissed) */
|
|
24
|
+
conflicts: ConflictRecord[];
|
|
25
|
+
/** Conflict records the user hasn't dismissed yet */
|
|
26
|
+
undismissedConflicts: ConflictRecord[];
|
|
27
|
+
/** Dismiss a single conflict notification by ID */
|
|
28
|
+
dismissConflict: (id: string) => void;
|
|
29
|
+
/** Clear all conflict records */
|
|
30
|
+
clearConflicts: () => void;
|
|
31
|
+
/** Returns true if the item exists only locally (not yet synced to server) */
|
|
32
|
+
isLocalOnly: (id: string) => boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Hook for consuming offline state, sync status, and conflict notifications.
|
|
37
|
+
*
|
|
38
|
+
* Usage:
|
|
39
|
+
* ```typescript
|
|
40
|
+
* const {isOnline, queueLength, isSyncing, undismissedConflicts, dismissConflict} = useOfflineStatus();
|
|
41
|
+
*
|
|
42
|
+
* if (!isOnline) {
|
|
43
|
+
* return <Banner text={`Offline. ${queueLength} changes pending.`} />;
|
|
44
|
+
* }
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export const useOfflineStatus = (): OfflineStatus => {
|
|
48
|
+
const dispatch = useDispatch();
|
|
49
|
+
const isOnline = useSelector((state: {offline: OfflineState}) => selectIsOnline(state));
|
|
50
|
+
const queueLength = useSelector((state: {offline: OfflineState}) => selectQueueLength(state));
|
|
51
|
+
const isSyncing = useSelector((state: {offline: OfflineState}) => selectIsSyncing(state));
|
|
52
|
+
const conflicts = useSelector((state: {offline: OfflineState}) => selectConflicts(state));
|
|
53
|
+
const undismissedConflicts = useSelector((state: {offline: OfflineState}) =>
|
|
54
|
+
selectUndismissedConflicts(state)
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const handleDismissConflict = useCallback(
|
|
58
|
+
(id: string) => {
|
|
59
|
+
dispatch(dismissConflict(id));
|
|
60
|
+
},
|
|
61
|
+
[dispatch]
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const handleClearConflicts = useCallback(() => {
|
|
65
|
+
dispatch(clearConflicts());
|
|
66
|
+
}, [dispatch]);
|
|
67
|
+
|
|
68
|
+
const isLocalOnly = useCallback((id: string): boolean => {
|
|
69
|
+
return typeof id === "string" && id.startsWith("temp-");
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
clearConflicts: handleClearConflicts,
|
|
74
|
+
conflicts,
|
|
75
|
+
dismissConflict: handleDismissConflict,
|
|
76
|
+
isLocalOnly,
|
|
77
|
+
isOnline,
|
|
78
|
+
isSyncing,
|
|
79
|
+
queueLength,
|
|
80
|
+
undismissedConflicts,
|
|
81
|
+
};
|
|
82
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import {useEffect, useState} from "react";
|
|
2
|
+
import {useSelector} from "react-redux";
|
|
3
|
+
|
|
4
|
+
import {isWebsocketsDebugEnabled, setRealtimeDebug} from "./constants";
|
|
5
|
+
import {selectIsOnlineSafe} from "./offlineSlice";
|
|
6
|
+
|
|
7
|
+
interface RealtimeHealthResponse {
|
|
8
|
+
debug?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Syncs websocket debug logging with the backend RealtimeApp debug flag.
|
|
13
|
+
* Also respects WEBSOCKETS_DEBUG from app config via isWebsocketsDebugEnabled().
|
|
14
|
+
*/
|
|
15
|
+
export const useRealtimeDebug = (baseUrl: string, refreshKey?: unknown): boolean => {
|
|
16
|
+
const [debugEnabled, setDebugEnabled] = useState(isWebsocketsDebugEnabled);
|
|
17
|
+
const isOnline = useSelector(selectIsOnlineSafe);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!isOnline) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let cancelled = false;
|
|
25
|
+
|
|
26
|
+
const loadDebugFlag = async (): Promise<void> => {
|
|
27
|
+
try {
|
|
28
|
+
const response = await fetch(`${baseUrl}/realtime/health`);
|
|
29
|
+
if (!response.ok) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const data = (await response.json()) as RealtimeHealthResponse;
|
|
33
|
+
if (cancelled) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
setRealtimeDebug(data.debug === true);
|
|
37
|
+
setDebugEnabled(isWebsocketsDebugEnabled());
|
|
38
|
+
} catch {
|
|
39
|
+
// Health endpoint unavailable — keep env-based debug only
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
void loadDebugFlag();
|
|
44
|
+
|
|
45
|
+
return (): void => {
|
|
46
|
+
cancelled = true;
|
|
47
|
+
};
|
|
48
|
+
}, [baseUrl, refreshKey, isOnline]);
|
|
49
|
+
|
|
50
|
+
return debugEnabled;
|
|
51
|
+
};
|