@terreno/rtk 0.25.0 → 0.27.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/.ai/guidelines/core.md +2 -0
- package/dist/authSlice.d.ts.map +1 -1
- package/dist/authSlice.js +3 -1
- package/dist/authSlice.js.map +1 -1
- package/dist/authSlice.test.js +15 -8
- package/dist/authSlice.test.js.map +1 -1
- package/dist/authSliceNative.test.js +3 -16
- package/dist/authSliceNative.test.js.map +1 -1
- package/dist/betterAuthClient.d.ts.map +1 -1
- package/dist/betterAuthClient.js +0 -1
- package/dist/betterAuthClient.js.map +1 -1
- package/dist/betterAuthSlice.test.js +1 -0
- package/dist/betterAuthSlice.test.js.map +1 -1
- package/dist/constants.d.ts +7 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js.map +1 -1
- package/dist/emptyApi.d.ts.map +1 -1
- package/dist/emptyApi.js +0 -2
- package/dist/emptyApi.js.map +1 -1
- package/dist/isolated/constants.isolated.d.ts +2 -0
- package/dist/isolated/constants.isolated.d.ts.map +1 -0
- package/dist/isolated/constants.isolated.js +81 -0
- package/dist/isolated/constants.isolated.js.map +1 -0
- package/dist/isolated/constants.test.js +53 -75
- package/dist/isolated/constants.test.js.map +1 -1
- package/dist/isolated/offlineMiddleware.isolated.js +0 -1
- package/dist/isolated/offlineMiddleware.isolated.js.map +1 -1
- package/dist/isolated/offlineMiddleware.native.isolated.js +0 -1
- package/dist/isolated/offlineMiddleware.native.isolated.js.map +1 -1
- package/dist/isolated/useUpgradeCheck.mobile.isolated.js +47 -0
- package/dist/isolated/useUpgradeCheck.mobile.isolated.js.map +1 -1
- package/dist/mongooseSlice.test.js +0 -1
- package/dist/mongooseSlice.test.js.map +1 -1
- package/dist/offlineGate.d.ts +1 -1
- package/dist/offlineGate.d.ts.map +1 -1
- package/dist/offlineGate.js +1 -3
- package/dist/offlineGate.js.map +1 -1
- package/dist/offlineMiddleware.d.ts.map +1 -1
- package/dist/offlineMiddleware.js +26 -0
- package/dist/offlineMiddleware.js.map +1 -1
- package/dist/offlineMiddleware.test.js +5 -1
- package/dist/offlineMiddleware.test.js.map +1 -1
- package/dist/realtime.test.js +1 -0
- package/dist/realtime.test.js.map +1 -1
- package/dist/sessionApi.test.d.ts +2 -0
- package/dist/sessionApi.test.d.ts.map +1 -0
- package/dist/sessionApi.test.js +84 -0
- package/dist/sessionApi.test.js.map +1 -0
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js.map +1 -1
- package/dist/sync.test.d.ts +2 -0
- package/dist/sync.test.d.ts.map +1 -0
- package/dist/sync.test.js +296 -0
- package/dist/sync.test.js.map +1 -0
- package/dist/testPreload.test.js +6 -0
- package/dist/testPreload.test.js.map +1 -1
- package/dist/useFeatureFlags.d.ts.map +1 -1
- package/dist/useFeatureFlags.js.map +1 -1
- package/dist/useTerrenoFeatureFlags.d.ts.map +1 -1
- package/dist/useTerrenoFeatureFlags.js.map +1 -1
- package/dist/useTerrenoFeatureFlags.test.js +10 -0
- package/dist/useTerrenoFeatureFlags.test.js.map +1 -1
- package/dist/useUpgradeCheckNative.test.js +21 -0
- package/dist/useUpgradeCheckNative.test.js.map +1 -1
- package/package.json +2 -2
- package/src/authSlice.test.ts +25 -13
- package/src/authSlice.ts +3 -1
- package/src/authSliceNative.test.ts +4 -18
- package/src/betterAuthClient.ts +1 -2
- package/src/betterAuthSlice.test.ts +1 -0
- package/src/constants.ts +7 -2
- package/src/emptyApi.ts +5 -4
- package/src/isolated/constants.isolated.ts +118 -0
- package/src/isolated/constants.test.ts +81 -78
- package/src/isolated/offlineMiddleware.isolated.ts +1 -2
- package/src/isolated/offlineMiddleware.native.isolated.ts +1 -2
- package/src/isolated/useUpgradeCheck.mobile.isolated.ts +60 -0
- package/src/mongooseSlice.test.ts +1 -2
- package/src/offlineGate.ts +1 -2
- package/src/offlineMiddleware.test.ts +6 -1
- package/src/offlineMiddleware.ts +27 -0
- package/src/realtime.test.ts +1 -0
- package/src/sessionApi.test.ts +115 -0
- package/src/sync.test.ts +390 -0
- package/src/sync.ts +2 -1
- package/src/testPreload.test.ts +7 -0
- package/src/useFeatureFlags.ts +1 -0
- package/src/useTerrenoFeatureFlags.test.ts +16 -0
- package/src/useTerrenoFeatureFlags.ts +2 -0
- package/src/useUpgradeCheckNative.test.ts +26 -0
package/src/offlineMiddleware.ts
CHANGED
|
@@ -28,6 +28,7 @@ export interface OfflineMiddlewareConfig {
|
|
|
28
28
|
/** RTK Query mutation endpoint names to queue when offline */
|
|
29
29
|
endpoints: string[];
|
|
30
30
|
/** The RTK Query API instance */
|
|
31
|
+
// noExplicitAny: Generic API type
|
|
31
32
|
// biome-ignore lint/suspicious/noExplicitAny: Generic API type
|
|
32
33
|
api: Api<any, any, any, any>;
|
|
33
34
|
}
|
|
@@ -54,6 +55,7 @@ const inferMutationType = (endpointName: string): "create" | "update" | "delete"
|
|
|
54
55
|
* not an auth or application-level FETCH_ERROR from emptyApi.
|
|
55
56
|
*/
|
|
56
57
|
export const isNetworkFetchError = (
|
|
58
|
+
// noExplicitAny: RTK Query error shapes vary by source
|
|
57
59
|
// biome-ignore lint/suspicious/noExplicitAny: RTK Query error shapes vary by source
|
|
58
60
|
source: any
|
|
59
61
|
): boolean => {
|
|
@@ -141,8 +143,10 @@ const inferGetByIdEndpointName = (endpointName: string): string => {
|
|
|
141
143
|
const extractBaseUpdatedAt = (
|
|
142
144
|
endpointName: string,
|
|
143
145
|
originalArgs: unknown,
|
|
146
|
+
// noExplicitAny: Generic getState
|
|
144
147
|
// biome-ignore lint/suspicious/noExplicitAny: Generic getState
|
|
145
148
|
getState: () => any,
|
|
149
|
+
// noExplicitAny: Generic API type
|
|
146
150
|
// biome-ignore lint/suspicious/noExplicitAny: Generic API type
|
|
147
151
|
api: Api<any, any, any, any>
|
|
148
152
|
): string | undefined => {
|
|
@@ -162,6 +166,7 @@ const extractBaseUpdatedAt = (
|
|
|
162
166
|
|
|
163
167
|
const getByIdEndpoint = inferGetByIdEndpointName(endpointName);
|
|
164
168
|
const state = getState();
|
|
169
|
+
// noExplicitAny: RTK Query internal state shape
|
|
165
170
|
// biome-ignore lint/suspicious/noExplicitAny: RTK Query internal state shape
|
|
166
171
|
const queries: Record<string, any> = state[api.reducerPath]?.queries ?? {};
|
|
167
172
|
for (const key of Object.keys(queries)) {
|
|
@@ -188,13 +193,16 @@ const extractBaseUpdatedAt = (
|
|
|
188
193
|
* args the consumer passed to the query hook, e.g. `{}` vs `undefined`).
|
|
189
194
|
*/
|
|
190
195
|
const getCachedQueryArgs = (
|
|
196
|
+
// noExplicitAny: Generic API state shape
|
|
191
197
|
// biome-ignore lint/suspicious/noExplicitAny: Generic API state shape
|
|
192
198
|
getState: () => any,
|
|
199
|
+
// noExplicitAny: Generic API type
|
|
193
200
|
// biome-ignore lint/suspicious/noExplicitAny: Generic API type
|
|
194
201
|
api: Api<any, any, any, any>,
|
|
195
202
|
listEndpointName: string
|
|
196
203
|
): unknown[] => {
|
|
197
204
|
const state = getState();
|
|
205
|
+
// noExplicitAny: RTK Query internal state shape
|
|
198
206
|
// biome-ignore lint/suspicious/noExplicitAny: RTK Query internal state shape
|
|
199
207
|
const queries: Record<string, any> = state[api.reducerPath]?.queries ?? {};
|
|
200
208
|
const cachedArgs: unknown[] = [];
|
|
@@ -213,10 +221,13 @@ const getCachedQueryArgs = (
|
|
|
213
221
|
* Apply an optimistic update to the RTK Query cache for a queued mutation.
|
|
214
222
|
*/
|
|
215
223
|
const applyOptimisticUpdate = (
|
|
224
|
+
// noExplicitAny: Generic API type
|
|
216
225
|
// biome-ignore lint/suspicious/noExplicitAny: Generic API type
|
|
217
226
|
api: Api<any, any, any, any>,
|
|
227
|
+
// noExplicitAny: Generic dispatch
|
|
218
228
|
// biome-ignore lint/suspicious/noExplicitAny: Generic dispatch
|
|
219
229
|
dispatch: any,
|
|
230
|
+
// noExplicitAny: Generic getState
|
|
220
231
|
// biome-ignore lint/suspicious/noExplicitAny: Generic getState
|
|
221
232
|
getState: () => any,
|
|
222
233
|
mutation: QueuedMutation
|
|
@@ -225,10 +236,12 @@ const applyOptimisticUpdate = (
|
|
|
225
236
|
const listEndpointName = `get${tagType.charAt(0).toUpperCase() + tagType.slice(1)}`;
|
|
226
237
|
const cachedArgs = getCachedQueryArgs(getState, api, listEndpointName);
|
|
227
238
|
|
|
239
|
+
// noExplicitAny: RTK Query cache shape varies by endpoint
|
|
228
240
|
// biome-ignore lint/suspicious/noExplicitAny: RTK Query cache shape varies by endpoint
|
|
229
241
|
const updateAllCacheEntries = (updater: (draft: any) => void): void => {
|
|
230
242
|
for (const queryArg of cachedArgs) {
|
|
231
243
|
dispatch(
|
|
244
|
+
// noExplicitAny: RTK Query cache shape varies by endpoint
|
|
232
245
|
// biome-ignore lint/suspicious/noExplicitAny: RTK Query cache shape varies by endpoint
|
|
233
246
|
api.util.updateQueryData(listEndpointName as any, queryArg, updater)
|
|
234
247
|
);
|
|
@@ -238,6 +251,7 @@ const applyOptimisticUpdate = (
|
|
|
238
251
|
if (mutation.type === "create") {
|
|
239
252
|
const args = mutation.args as {body?: Record<string, unknown>};
|
|
240
253
|
const tempItem = buildOptimisticCreateItem(mutation, args?.body);
|
|
254
|
+
// noExplicitAny: RTK Query cache shape varies by endpoint
|
|
241
255
|
// biome-ignore lint/suspicious/noExplicitAny: RTK Query cache shape varies by endpoint
|
|
242
256
|
updateAllCacheEntries((draft: any) => {
|
|
243
257
|
if (draft?.data && Array.isArray(draft.data)) {
|
|
@@ -247,6 +261,7 @@ const applyOptimisticUpdate = (
|
|
|
247
261
|
} else if (mutation.type === "update") {
|
|
248
262
|
const args = mutation.args as {id?: string; body?: Record<string, unknown>};
|
|
249
263
|
if (args?.id && args?.body) {
|
|
264
|
+
// noExplicitAny: RTK Query cache shape varies by endpoint
|
|
250
265
|
// biome-ignore lint/suspicious/noExplicitAny: RTK Query cache shape varies by endpoint
|
|
251
266
|
updateAllCacheEntries((draft: any) => {
|
|
252
267
|
if (draft?.data && Array.isArray(draft.data)) {
|
|
@@ -262,6 +277,7 @@ const applyOptimisticUpdate = (
|
|
|
262
277
|
} else if (mutation.type === "delete") {
|
|
263
278
|
const args = mutation.args as {id?: string};
|
|
264
279
|
if (args?.id) {
|
|
280
|
+
// noExplicitAny: RTK Query cache shape varies by endpoint
|
|
265
281
|
// biome-ignore lint/suspicious/noExplicitAny: RTK Query cache shape varies by endpoint
|
|
266
282
|
updateAllCacheEntries((draft: any) => {
|
|
267
283
|
if (draft?.data && Array.isArray(draft.data)) {
|
|
@@ -278,10 +294,13 @@ const applyOptimisticUpdate = (
|
|
|
278
294
|
* Remove optimistic temp items from the cache after they've been replayed.
|
|
279
295
|
*/
|
|
280
296
|
const removeTempItems = (
|
|
297
|
+
// noExplicitAny: Generic API type
|
|
281
298
|
// biome-ignore lint/suspicious/noExplicitAny: Generic API type
|
|
282
299
|
api: Api<any, any, any, any>,
|
|
300
|
+
// noExplicitAny: Generic dispatch
|
|
283
301
|
// biome-ignore lint/suspicious/noExplicitAny: Generic dispatch
|
|
284
302
|
dispatch: any,
|
|
303
|
+
// noExplicitAny: Generic getState
|
|
285
304
|
// biome-ignore lint/suspicious/noExplicitAny: Generic getState
|
|
286
305
|
getState: () => any,
|
|
287
306
|
mutations: QueuedMutation[]
|
|
@@ -295,6 +314,7 @@ const removeTempItems = (
|
|
|
295
314
|
|
|
296
315
|
for (const queryArg of cachedArgs) {
|
|
297
316
|
dispatch(
|
|
317
|
+
// noExplicitAny: RTK Query cache shape varies by endpoint
|
|
298
318
|
// biome-ignore lint/suspicious/noExplicitAny: RTK Query cache shape varies by endpoint
|
|
299
319
|
api.util.updateQueryData(listEndpointName as any, queryArg, (draft: any) => {
|
|
300
320
|
if (draft?.data && Array.isArray(draft.data)) {
|
|
@@ -369,6 +389,7 @@ const replayMutation = async (
|
|
|
369
389
|
* Web apps should use `useServerStatus` for server-level health checking.
|
|
370
390
|
*/
|
|
371
391
|
const setupNativeNetworkMonitoring = (
|
|
392
|
+
// noExplicitAny: Generic dispatch
|
|
372
393
|
// biome-ignore lint/suspicious/noExplicitAny: Generic dispatch
|
|
373
394
|
dispatch: any
|
|
374
395
|
): (() => void) => {
|
|
@@ -438,6 +459,7 @@ export const createOfflineMiddleware = (
|
|
|
438
459
|
// RTK Query dispatches actions with type "terreno-rtk/executeMutation/rejected"
|
|
439
460
|
// when a mutation fails. Queue the mutation and apply an optimistic cache update.
|
|
440
461
|
listenerMiddleware.startListening({
|
|
462
|
+
// noExplicitAny: RTK Query action types are complex
|
|
441
463
|
// biome-ignore lint/suspicious/noExplicitAny: RTK Query action types are complex
|
|
442
464
|
effect: async (action: any, listenerApi) => {
|
|
443
465
|
const state = listenerApi.getState() as {offline: OfflineState};
|
|
@@ -464,11 +486,13 @@ export const createOfflineMiddleware = (
|
|
|
464
486
|
const listEndpointName = `get${tagType.charAt(0).toUpperCase() + tagType.slice(1)}`;
|
|
465
487
|
const cachedArgs = getCachedQueryArgs(listenerApi.getState, api, listEndpointName);
|
|
466
488
|
for (const queryArg of cachedArgs) {
|
|
489
|
+
// noExplicitAny: RTK Query cache shape and endpoint types vary
|
|
467
490
|
// biome-ignore lint/suspicious/noExplicitAny: RTK Query cache shape and endpoint types vary
|
|
468
491
|
const endpoint = (api.endpoints as any)[listEndpointName];
|
|
469
492
|
if (!endpoint?.select) {
|
|
470
493
|
continue;
|
|
471
494
|
}
|
|
495
|
+
// noExplicitAny: RTK Query cache shape varies
|
|
472
496
|
// biome-ignore lint/suspicious/noExplicitAny: RTK Query cache shape varies
|
|
473
497
|
const cacheEntry = endpoint.select(queryArg)(listenerApi.getState() as any) as any;
|
|
474
498
|
const items = cacheEntry?.data?.data;
|
|
@@ -513,6 +537,7 @@ export const createOfflineMiddleware = (
|
|
|
513
537
|
listenerApi.dispatch(setOnlineStatus(false));
|
|
514
538
|
}
|
|
515
539
|
},
|
|
540
|
+
// noExplicitAny: RTK Query internal action shape
|
|
516
541
|
// biome-ignore lint/suspicious/noExplicitAny: RTK Query internal action shape
|
|
517
542
|
predicate: (action: any) => {
|
|
518
543
|
if (typeof action?.type !== "string") {
|
|
@@ -535,6 +560,7 @@ export const createOfflineMiddleware = (
|
|
|
535
560
|
listenerApi.dispatch(setOnlineStatus(false));
|
|
536
561
|
}
|
|
537
562
|
},
|
|
563
|
+
// noExplicitAny: RTK Query internal action shape
|
|
538
564
|
// biome-ignore lint/suspicious/noExplicitAny: RTK Query internal action shape
|
|
539
565
|
predicate: (action: any) => {
|
|
540
566
|
if (typeof action?.type !== "string") {
|
|
@@ -557,6 +583,7 @@ export const createOfflineMiddleware = (
|
|
|
557
583
|
listenerApi.dispatch(setOnlineStatus(true));
|
|
558
584
|
}
|
|
559
585
|
},
|
|
586
|
+
// noExplicitAny: RTK Query internal action shape
|
|
560
587
|
// biome-ignore lint/suspicious/noExplicitAny: RTK Query internal action shape
|
|
561
588
|
predicate: (action: any) => {
|
|
562
589
|
if (typeof action?.type !== "string") {
|
package/src/realtime.test.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// noExplicitAny: realtime RTK tests mock Socket.io and RTK Query runtime shapes
|
|
1
2
|
// biome-ignore-all lint/suspicious/noExplicitAny: realtime RTK tests mock Socket.io and RTK Query runtime shapes
|
|
2
3
|
import {afterEach, describe, expect, it} from "bun:test";
|
|
3
4
|
import type {Socket} from "socket.io-client";
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import {afterEach, describe, expect, it, mock} from "bun:test";
|
|
2
|
+
import {configureStore} from "@reduxjs/toolkit";
|
|
3
|
+
|
|
4
|
+
import {createSessionApi} from "./sessionApi";
|
|
5
|
+
|
|
6
|
+
type FetchArgs = Parameters<typeof fetch>;
|
|
7
|
+
|
|
8
|
+
interface RecordedFetch {
|
|
9
|
+
urls: string[];
|
|
10
|
+
handler: (input: FetchArgs[0], init: FetchArgs[1]) => Response;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const jsonResponse = (body: unknown, status = 200): Response =>
|
|
14
|
+
new Response(JSON.stringify(body), {
|
|
15
|
+
headers: {"Content-Type": "application/json"},
|
|
16
|
+
status,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const originalFetch = global.fetch;
|
|
20
|
+
let recorded: RecordedFetch;
|
|
21
|
+
|
|
22
|
+
const setFetch = (handler: RecordedFetch["handler"]): void => {
|
|
23
|
+
recorded = {handler, urls: []};
|
|
24
|
+
global.fetch = mock((input: FetchArgs[0], init: FetchArgs[1]) => {
|
|
25
|
+
const url =
|
|
26
|
+
typeof input === "string" ? input : input instanceof Request ? input.url : String(input);
|
|
27
|
+
recorded.urls.push(url);
|
|
28
|
+
return Promise.resolve(handler(input, init));
|
|
29
|
+
}) as unknown as typeof fetch;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const buildStore = () => {
|
|
33
|
+
const api = createSessionApi({baseUrl: "http://localhost:9999"});
|
|
34
|
+
const withEndpoints = api.injectEndpoints({
|
|
35
|
+
endpoints: (build) => ({
|
|
36
|
+
getThing: build.query<unknown, void>({query: () => "/thing"}),
|
|
37
|
+
getWithParams: build.query<unknown, void>({
|
|
38
|
+
query: () => ({params: {ids: {$in: [1, 2]}, status: "active"}, url: "/things"}),
|
|
39
|
+
}),
|
|
40
|
+
}),
|
|
41
|
+
overrideExisting: true,
|
|
42
|
+
});
|
|
43
|
+
const store = configureStore({
|
|
44
|
+
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(api.middleware),
|
|
45
|
+
reducer: {[api.reducerPath]: api.reducer},
|
|
46
|
+
});
|
|
47
|
+
return {api, store, withEndpoints};
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
describe("createSessionApi", () => {
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
global.fetch = originalFetch;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("defaults reducerPath to terreno-session", () => {
|
|
56
|
+
const api = createSessionApi();
|
|
57
|
+
expect(api.reducerPath).toBe("terreno-session");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("uses a custom reducerPath when provided", () => {
|
|
61
|
+
const api = createSessionApi({reducerPath: "admin-session"});
|
|
62
|
+
expect(api.reducerPath).toBe("admin-session");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("responseHandler", () => {
|
|
66
|
+
it("returns null for a 204 No Content response", async () => {
|
|
67
|
+
setFetch(() => new Response(null, {status: 204}));
|
|
68
|
+
const {store, withEndpoints} = buildStore();
|
|
69
|
+
|
|
70
|
+
const result = await store.dispatch(withEndpoints.endpoints.getThing.initiate());
|
|
71
|
+
|
|
72
|
+
expect(result.error).toBeUndefined();
|
|
73
|
+
expect(result.data).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("returns the whole payload for list responses containing `more`", async () => {
|
|
77
|
+
const payload = {data: [{id: "1"}], more: false, page: 1};
|
|
78
|
+
setFetch(() => jsonResponse(payload));
|
|
79
|
+
const {store, withEndpoints} = buildStore();
|
|
80
|
+
|
|
81
|
+
const result = await store.dispatch(withEndpoints.endpoints.getThing.initiate());
|
|
82
|
+
|
|
83
|
+
expect(result.data).toEqual(payload);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("unwraps the `data` envelope for single-resource responses", async () => {
|
|
87
|
+
setFetch(() => jsonResponse({data: {id: "42", name: "answer"}}));
|
|
88
|
+
const {store, withEndpoints} = buildStore();
|
|
89
|
+
|
|
90
|
+
const result = await store.dispatch(withEndpoints.endpoints.getThing.initiate());
|
|
91
|
+
|
|
92
|
+
expect(result.data).toEqual({id: "42", name: "answer"});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("returns the raw result when there is no `data` envelope or `more` field", async () => {
|
|
96
|
+
setFetch(() => jsonResponse({id: "7", name: "raw"}));
|
|
97
|
+
const {store, withEndpoints} = buildStore();
|
|
98
|
+
|
|
99
|
+
const result = await store.dispatch(withEndpoints.endpoints.getThing.initiate());
|
|
100
|
+
|
|
101
|
+
expect(result.data).toEqual({id: "7", name: "raw"});
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("serializes nested query params with qs", async () => {
|
|
106
|
+
setFetch(() => jsonResponse({id: "1"}));
|
|
107
|
+
const {store, withEndpoints} = buildStore();
|
|
108
|
+
|
|
109
|
+
await store.dispatch(withEndpoints.endpoints.getWithParams.initiate());
|
|
110
|
+
|
|
111
|
+
const requestedUrl = recorded.urls[0];
|
|
112
|
+
expect(requestedUrl).toContain("ids%5B%24in%5D%5B0%5D=1");
|
|
113
|
+
expect(requestedUrl).toContain("status=active");
|
|
114
|
+
});
|
|
115
|
+
});
|
package/src/sync.test.ts
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import {describe, expect, it, mock} 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
|
+
import type {Socket} from "socket.io-client";
|
|
7
|
+
|
|
8
|
+
import type {RealtimeEvent} from "./realtime";
|
|
9
|
+
import {useSyncConnection} from "./sync";
|
|
10
|
+
|
|
11
|
+
const REDUCER_PATH = "testApi";
|
|
12
|
+
|
|
13
|
+
type SocketHandler = (arg?: unknown) => void;
|
|
14
|
+
|
|
15
|
+
interface FakeSocket {
|
|
16
|
+
connected: boolean;
|
|
17
|
+
emit: ReturnType<typeof mock>;
|
|
18
|
+
on: ReturnType<typeof mock>;
|
|
19
|
+
off: ReturnType<typeof mock>;
|
|
20
|
+
trigger: (event: string, arg?: unknown) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const createFakeSocket = (connected: boolean): FakeSocket => {
|
|
24
|
+
const handlers: Record<string, SocketHandler[]> = {};
|
|
25
|
+
return {
|
|
26
|
+
connected,
|
|
27
|
+
emit: mock(() => {}),
|
|
28
|
+
off: mock((event: string, cb: SocketHandler) => {
|
|
29
|
+
handlers[event] = (handlers[event] ?? []).filter((h) => h !== cb);
|
|
30
|
+
}),
|
|
31
|
+
on: mock((event: string, cb: SocketHandler) => {
|
|
32
|
+
handlers[event] = handlers[event] ?? [];
|
|
33
|
+
handlers[event].push(cb);
|
|
34
|
+
}),
|
|
35
|
+
trigger: (event: string, arg?: unknown) => {
|
|
36
|
+
for (const cb of handlers[event] ?? []) {
|
|
37
|
+
cb(arg);
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
interface QueryEntry {
|
|
44
|
+
status: string;
|
|
45
|
+
endpointName: string;
|
|
46
|
+
originalArgs: unknown;
|
|
47
|
+
data?: Record<string, unknown>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const createApi = (queries: Record<string, QueryEntry> | null) => {
|
|
51
|
+
const dataByEndpoint: Record<string, Record<string, unknown>> = {};
|
|
52
|
+
if (queries) {
|
|
53
|
+
for (const key of Object.keys(queries)) {
|
|
54
|
+
const entry = queries[key];
|
|
55
|
+
if (entry.data) {
|
|
56
|
+
dataByEndpoint[entry.endpointName] = entry.data;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const invalidateTags = mock((tags: string[]) => ({payload: tags, type: "api/invalidateTags"}));
|
|
62
|
+
const updateQueryData = mock(
|
|
63
|
+
(endpointName: string, _args: unknown, recipe: (draft: Record<string, unknown>) => void) => {
|
|
64
|
+
const draft = dataByEndpoint[endpointName];
|
|
65
|
+
if (draft) {
|
|
66
|
+
recipe(draft);
|
|
67
|
+
}
|
|
68
|
+
return {type: "api/updateQueryData"};
|
|
69
|
+
}
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const api = {
|
|
73
|
+
reducerPath: REDUCER_PATH,
|
|
74
|
+
util: {invalidateTags, updateQueryData},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const store = configureStore({
|
|
78
|
+
middleware: (getDefaultMiddleware) =>
|
|
79
|
+
getDefaultMiddleware({immutableCheck: false, serializableCheck: false}),
|
|
80
|
+
reducer: {
|
|
81
|
+
[REDUCER_PATH]: (state = {queries: queries ?? undefined}) => state,
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return {api, invalidateTags, store, updateQueryData};
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const renderSync = (
|
|
89
|
+
socket: FakeSocket | null,
|
|
90
|
+
api: ReturnType<typeof createApi>["api"],
|
|
91
|
+
store: ReturnType<typeof configureStore>,
|
|
92
|
+
options: {tagTypes?: string[]; debug?: boolean} = {}
|
|
93
|
+
) => {
|
|
94
|
+
const Wrapper: React.FC<{children: React.ReactNode}> = ({children}) =>
|
|
95
|
+
React.createElement(Provider, {children, store});
|
|
96
|
+
return renderHook(
|
|
97
|
+
() =>
|
|
98
|
+
useSyncConnection({
|
|
99
|
+
api,
|
|
100
|
+
debug: options.debug ?? true,
|
|
101
|
+
socket: socket as unknown as Socket,
|
|
102
|
+
tagTypes: options.tagTypes ?? ["todos"],
|
|
103
|
+
}),
|
|
104
|
+
{wrapper: Wrapper}
|
|
105
|
+
);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const syncEvent = (overrides: Partial<RealtimeEvent>): RealtimeEvent =>
|
|
109
|
+
({
|
|
110
|
+
collection: "todos",
|
|
111
|
+
id: "1",
|
|
112
|
+
method: "update",
|
|
113
|
+
...overrides,
|
|
114
|
+
}) as RealtimeEvent;
|
|
115
|
+
|
|
116
|
+
describe("useSyncConnection", () => {
|
|
117
|
+
it("does nothing when there is no socket", () => {
|
|
118
|
+
const {store, api} = createApi(null);
|
|
119
|
+
const {result} = renderSync(null, api, store);
|
|
120
|
+
expect(result.current).toBeUndefined();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("subscribes to model rooms when the socket is already connected", () => {
|
|
124
|
+
const socket = createFakeSocket(true);
|
|
125
|
+
const {store, api} = createApi({});
|
|
126
|
+
const {unmount} = renderSync(socket, api, store, {tagTypes: ["todos", "users"]});
|
|
127
|
+
|
|
128
|
+
expect(socket.emit).toHaveBeenCalledWith("subscribe:model", "todos");
|
|
129
|
+
expect(socket.emit).toHaveBeenCalledWith("subscribe:model", "users");
|
|
130
|
+
expect(socket.on).toHaveBeenCalledWith("connect", expect.any(Function));
|
|
131
|
+
expect(socket.on).toHaveBeenCalledWith("sync", expect.any(Function));
|
|
132
|
+
|
|
133
|
+
unmount();
|
|
134
|
+
expect(socket.emit).toHaveBeenCalledWith("unsubscribe:model", "todos");
|
|
135
|
+
expect(socket.off).toHaveBeenCalledWith("sync", expect.any(Function));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("subscribes on the connect event when not initially connected", () => {
|
|
139
|
+
const socket = createFakeSocket(false);
|
|
140
|
+
const {store, api} = createApi({});
|
|
141
|
+
const {unmount} = renderSync(socket, api, store);
|
|
142
|
+
|
|
143
|
+
expect(socket.emit).not.toHaveBeenCalledWith("subscribe:model", "todos");
|
|
144
|
+
|
|
145
|
+
act(() => {
|
|
146
|
+
socket.trigger("connect");
|
|
147
|
+
});
|
|
148
|
+
expect(socket.emit).toHaveBeenCalledWith("subscribe:model", "todos");
|
|
149
|
+
|
|
150
|
+
// Not connected on cleanup, so no unsubscribe emit.
|
|
151
|
+
socket.emit.mockClear();
|
|
152
|
+
unmount();
|
|
153
|
+
expect(socket.emit).not.toHaveBeenCalledWith("unsubscribe:model", "todos");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("ignores events for collections that are not tracked", () => {
|
|
157
|
+
const socket = createFakeSocket(true);
|
|
158
|
+
const {store, api, invalidateTags} = createApi({});
|
|
159
|
+
renderSync(socket, api, store);
|
|
160
|
+
|
|
161
|
+
act(() => {
|
|
162
|
+
socket.trigger("sync", syncEvent({collection: "other", method: "create"}));
|
|
163
|
+
});
|
|
164
|
+
expect(invalidateTags).not.toHaveBeenCalled();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("invalidates tags for create events", () => {
|
|
168
|
+
const socket = createFakeSocket(true);
|
|
169
|
+
const {store, api, invalidateTags} = createApi({});
|
|
170
|
+
renderSync(socket, api, store);
|
|
171
|
+
|
|
172
|
+
act(() => {
|
|
173
|
+
socket.trigger("sync", syncEvent({method: "create"}));
|
|
174
|
+
});
|
|
175
|
+
expect(invalidateTags).toHaveBeenCalledWith(["todos"]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("invalidates tags for update events without data", () => {
|
|
179
|
+
const socket = createFakeSocket(true);
|
|
180
|
+
const {store, api, invalidateTags} = createApi({});
|
|
181
|
+
renderSync(socket, api, store);
|
|
182
|
+
|
|
183
|
+
act(() => {
|
|
184
|
+
socket.trigger("sync", syncEvent({data: undefined, method: "update"}));
|
|
185
|
+
});
|
|
186
|
+
expect(invalidateTags).toHaveBeenCalledWith(["todos"]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("invalidates tags for update events when there are no cached queries", () => {
|
|
190
|
+
const socket = createFakeSocket(true);
|
|
191
|
+
const {store, api, invalidateTags} = createApi(null);
|
|
192
|
+
renderSync(socket, api, store);
|
|
193
|
+
|
|
194
|
+
act(() => {
|
|
195
|
+
socket.trigger("sync", syncEvent({data: {name: "new"}, method: "update"}));
|
|
196
|
+
});
|
|
197
|
+
expect(invalidateTags).toHaveBeenCalledWith(["todos"]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("patches a matching entity inside a cached list query on update", () => {
|
|
201
|
+
const socket = createFakeSocket(true);
|
|
202
|
+
const queries = {
|
|
203
|
+
"getTodos()": {
|
|
204
|
+
data: {data: [{_id: "1", name: "old", updated: "2026-01-01T00:00:00.000Z"}], total: 1},
|
|
205
|
+
endpointName: "getTodos",
|
|
206
|
+
originalArgs: undefined,
|
|
207
|
+
status: "fulfilled",
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
const {store, api, updateQueryData, invalidateTags} = createApi(queries);
|
|
211
|
+
renderSync(socket, api, store);
|
|
212
|
+
|
|
213
|
+
act(() => {
|
|
214
|
+
socket.trigger(
|
|
215
|
+
"sync",
|
|
216
|
+
syncEvent({data: {name: "new", updated: "2026-02-01T00:00:00.000Z"}, method: "update"})
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
expect(updateQueryData).toHaveBeenCalled();
|
|
221
|
+
expect(queries["getTodos()"].data.data[0].name).toBe("new");
|
|
222
|
+
expect(invalidateTags).not.toHaveBeenCalled();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("skips stale updates in a cached list query", () => {
|
|
226
|
+
const socket = createFakeSocket(true);
|
|
227
|
+
const queries = {
|
|
228
|
+
"getTodos()": {
|
|
229
|
+
data: {data: [{_id: "1", name: "current", updated: "2026-03-01T00:00:00.000Z"}], total: 1},
|
|
230
|
+
endpointName: "getTodos",
|
|
231
|
+
originalArgs: undefined,
|
|
232
|
+
status: "fulfilled",
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
const {store, api} = createApi(queries);
|
|
236
|
+
renderSync(socket, api, store);
|
|
237
|
+
|
|
238
|
+
act(() => {
|
|
239
|
+
socket.trigger(
|
|
240
|
+
"sync",
|
|
241
|
+
syncEvent({data: {name: "stale", updated: "2026-01-01T00:00:00.000Z"}, method: "update"})
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
expect(queries["getTodos()"].data.data[0].name).toBe("current");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("patches a matching single-entity cached query on update", () => {
|
|
249
|
+
const socket = createFakeSocket(true);
|
|
250
|
+
const queries = {
|
|
251
|
+
"getTodoById(1)": {
|
|
252
|
+
data: {_id: "1", name: "old", updated: "2026-01-01T00:00:00.000Z"},
|
|
253
|
+
endpointName: "getTodoById",
|
|
254
|
+
originalArgs: {id: "1"},
|
|
255
|
+
status: "fulfilled",
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
const {store, api, updateQueryData} = createApi(queries);
|
|
259
|
+
renderSync(socket, api, store);
|
|
260
|
+
|
|
261
|
+
act(() => {
|
|
262
|
+
socket.trigger(
|
|
263
|
+
"sync",
|
|
264
|
+
syncEvent({data: {name: "new", updated: "2026-02-01T00:00:00.000Z"}, method: "update"})
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
expect(updateQueryData).toHaveBeenCalled();
|
|
269
|
+
expect(queries["getTodoById(1)"].data.name).toBe("new");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("skips stale updates for a single-entity cached query", () => {
|
|
273
|
+
const socket = createFakeSocket(true);
|
|
274
|
+
const queries = {
|
|
275
|
+
"getTodoById(1)": {
|
|
276
|
+
data: {_id: "1", name: "current", updated: "2026-03-01T00:00:00.000Z"},
|
|
277
|
+
endpointName: "getTodoById",
|
|
278
|
+
originalArgs: {id: "1"},
|
|
279
|
+
status: "fulfilled",
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
const {store, api, invalidateTags} = createApi(queries);
|
|
283
|
+
renderSync(socket, api, store);
|
|
284
|
+
|
|
285
|
+
act(() => {
|
|
286
|
+
socket.trigger(
|
|
287
|
+
"sync",
|
|
288
|
+
syncEvent({data: {name: "stale", updated: "2026-01-01T00:00:00.000Z"}, method: "update"})
|
|
289
|
+
);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
expect(queries["getTodoById(1)"].data.name).toBe("current");
|
|
293
|
+
expect(invalidateTags).toHaveBeenCalledWith(["todos"]);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("invalidates tags on update when no cached query matches", () => {
|
|
297
|
+
const socket = createFakeSocket(true);
|
|
298
|
+
const queries = {
|
|
299
|
+
"getOthers()": {
|
|
300
|
+
data: {data: [{_id: "999", name: "unrelated"}]},
|
|
301
|
+
endpointName: "getOthers",
|
|
302
|
+
originalArgs: undefined,
|
|
303
|
+
status: "fulfilled",
|
|
304
|
+
},
|
|
305
|
+
"pending()": {
|
|
306
|
+
data: {data: [{_id: "1"}]},
|
|
307
|
+
endpointName: "pending",
|
|
308
|
+
originalArgs: undefined,
|
|
309
|
+
status: "pending",
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
const {store, api, invalidateTags} = createApi(queries);
|
|
313
|
+
renderSync(socket, api, store);
|
|
314
|
+
|
|
315
|
+
act(() => {
|
|
316
|
+
socket.trigger("sync", syncEvent({data: {name: "new"}, method: "update"}));
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
expect(invalidateTags).toHaveBeenCalledWith(["todos"]);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("removes a deleted entity from cached list queries and decrements total", () => {
|
|
323
|
+
const socket = createFakeSocket(true);
|
|
324
|
+
const queries = {
|
|
325
|
+
"getTodos()": {
|
|
326
|
+
data: {
|
|
327
|
+
data: [
|
|
328
|
+
{_id: "1", name: "a"},
|
|
329
|
+
{_id: "2", name: "b"},
|
|
330
|
+
],
|
|
331
|
+
total: 2,
|
|
332
|
+
},
|
|
333
|
+
endpointName: "getTodos",
|
|
334
|
+
originalArgs: undefined,
|
|
335
|
+
status: "fulfilled",
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
const {store, api, updateQueryData} = createApi(queries);
|
|
339
|
+
renderSync(socket, api, store);
|
|
340
|
+
|
|
341
|
+
act(() => {
|
|
342
|
+
socket.trigger("sync", syncEvent({method: "delete"}));
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
expect(updateQueryData).toHaveBeenCalled();
|
|
346
|
+
expect(queries["getTodos()"].data.data).toHaveLength(1);
|
|
347
|
+
expect(queries["getTodos()"].data.total).toBe(1);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("invalidates tags on delete when there are no cached queries", () => {
|
|
351
|
+
const socket = createFakeSocket(true);
|
|
352
|
+
const {store, api, invalidateTags} = createApi(null);
|
|
353
|
+
renderSync(socket, api, store);
|
|
354
|
+
|
|
355
|
+
act(() => {
|
|
356
|
+
socket.trigger("sync", syncEvent({method: "delete"}));
|
|
357
|
+
});
|
|
358
|
+
expect(invalidateTags).toHaveBeenCalledWith(["todos"]);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("invalidates tags on delete when no cached query matches", () => {
|
|
362
|
+
const socket = createFakeSocket(true);
|
|
363
|
+
const queries = {
|
|
364
|
+
"getOthers()": {
|
|
365
|
+
data: {data: [{_id: "999"}]},
|
|
366
|
+
endpointName: "getOthers",
|
|
367
|
+
originalArgs: undefined,
|
|
368
|
+
status: "fulfilled",
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
const {store, api, invalidateTags} = createApi(queries);
|
|
372
|
+
renderSync(socket, api, store);
|
|
373
|
+
|
|
374
|
+
act(() => {
|
|
375
|
+
socket.trigger("sync", syncEvent({method: "delete"}));
|
|
376
|
+
});
|
|
377
|
+
expect(invalidateTags).toHaveBeenCalledWith(["todos"]);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("does not log sync details when debug is disabled", () => {
|
|
381
|
+
const socket = createFakeSocket(true);
|
|
382
|
+
const {store, api, invalidateTags} = createApi({});
|
|
383
|
+
renderSync(socket, api, store, {debug: false});
|
|
384
|
+
|
|
385
|
+
act(() => {
|
|
386
|
+
socket.trigger("sync", syncEvent({method: "create"}));
|
|
387
|
+
});
|
|
388
|
+
expect(invalidateTags).toHaveBeenCalledWith(["todos"]);
|
|
389
|
+
});
|
|
390
|
+
});
|
package/src/sync.ts
CHANGED
|
@@ -40,7 +40,8 @@ interface UseSyncConnectionOptions {
|
|
|
40
40
|
/** Socket.io client instance (from useSocketConnection) */
|
|
41
41
|
socket: Socket | null;
|
|
42
42
|
/** RTK Query API instance (the enhanced API with tag types) */
|
|
43
|
-
//
|
|
43
|
+
// noExplicitAny: RTK Query Api generic requires four app-specific type parameters
|
|
44
|
+
// biome-ignore lint/suspicious/noExplicitAny: RTK Query Api generic requires four app-specific type parameters
|
|
44
45
|
api: any;
|
|
45
46
|
/** Tag types to listen for (e.g. ["todos", "users"]) — these should match the collection field in events */
|
|
46
47
|
tagTypes: string[];
|