@terreno/rtk 0.13.3 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/dist/authSlice.d.ts +4 -4
  2. package/dist/authSlice.d.ts.map +1 -1
  3. package/dist/authSlice.js +7 -7
  4. package/dist/authSlice.js.map +1 -1
  5. package/dist/authSlice.test.js +2 -10
  6. package/dist/authSlice.test.js.map +1 -1
  7. package/dist/authSliceNative.test.js +11 -3
  8. package/dist/authSliceNative.test.js.map +1 -1
  9. package/dist/betterAuthSlice.d.ts.map +1 -1
  10. package/dist/betterAuthSlice.js +3 -2
  11. package/dist/betterAuthSlice.js.map +1 -1
  12. package/dist/betterAuthSlice.test.js +1 -25
  13. package/dist/betterAuthSlice.test.js.map +1 -1
  14. package/dist/constants.d.ts +3 -0
  15. package/dist/constants.d.ts.map +1 -1
  16. package/dist/constants.js +11 -3
  17. package/dist/constants.js.map +1 -1
  18. package/dist/constants.test.js +85 -2
  19. package/dist/constants.test.js.map +1 -1
  20. package/dist/emptyApi.d.ts +3 -1
  21. package/dist/emptyApi.d.ts.map +1 -1
  22. package/dist/emptyApi.js +7 -2
  23. package/dist/emptyApi.js.map +1 -1
  24. package/dist/index.d.ts +8 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +8 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/isolated/constants.isolated.js +7 -1
  29. package/dist/isolated/constants.isolated.js.map +1 -1
  30. package/dist/isolated/offlineMiddleware.isolated.d.ts +2 -0
  31. package/dist/isolated/offlineMiddleware.isolated.d.ts.map +1 -0
  32. package/dist/isolated/offlineMiddleware.isolated.js +516 -0
  33. package/dist/isolated/offlineMiddleware.isolated.js.map +1 -0
  34. package/dist/isolated/offlineMiddleware.native.isolated.d.ts +2 -0
  35. package/dist/isolated/offlineMiddleware.native.isolated.d.ts.map +1 -0
  36. package/dist/isolated/offlineMiddleware.native.isolated.js +70 -0
  37. package/dist/isolated/offlineMiddleware.native.isolated.js.map +1 -0
  38. package/dist/isolated/useUpgradeCheck.mobile.isolated.d.ts +2 -0
  39. package/dist/isolated/useUpgradeCheck.mobile.isolated.d.ts.map +1 -0
  40. package/dist/{useUpgradeCheck.mobile.test.js → isolated/useUpgradeCheck.mobile.isolated.js} +13 -5
  41. package/dist/isolated/useUpgradeCheck.mobile.isolated.js.map +1 -0
  42. package/dist/isolated/useUpgradeCheck.web.isolated.d.ts +2 -0
  43. package/dist/isolated/useUpgradeCheck.web.isolated.d.ts.map +1 -0
  44. package/dist/{useUpgradeCheck.test.js → isolated/useUpgradeCheck.web.isolated.js} +37 -7
  45. package/dist/isolated/useUpgradeCheck.web.isolated.js.map +1 -0
  46. package/dist/mongooseSlice.d.ts +3 -2
  47. package/dist/mongooseSlice.d.ts.map +1 -1
  48. package/dist/mongooseSlice.js +3 -4
  49. package/dist/mongooseSlice.js.map +1 -1
  50. package/dist/mongooseSlice.test.js +1 -1
  51. package/dist/mongooseSlice.test.js.map +1 -1
  52. package/dist/offlineGate.d.ts +8 -0
  53. package/dist/offlineGate.d.ts.map +1 -0
  54. package/dist/offlineGate.js +20 -0
  55. package/dist/offlineGate.js.map +1 -0
  56. package/dist/offlineGate.test.d.ts +2 -0
  57. package/dist/offlineGate.test.d.ts.map +1 -0
  58. package/dist/offlineGate.test.js +46 -0
  59. package/dist/offlineGate.test.js.map +1 -0
  60. package/dist/offlineMiddleware.d.ts +46 -0
  61. package/dist/offlineMiddleware.d.ts.map +1 -0
  62. package/dist/offlineMiddleware.js +589 -0
  63. package/dist/offlineMiddleware.js.map +1 -0
  64. package/dist/offlineMiddleware.test.d.ts +2 -0
  65. package/dist/offlineMiddleware.test.d.ts.map +1 -0
  66. package/dist/offlineMiddleware.test.js +145 -0
  67. package/dist/offlineMiddleware.test.js.map +1 -0
  68. package/dist/offlineSlice.d.ts +232 -0
  69. package/dist/offlineSlice.d.ts.map +1 -0
  70. package/dist/offlineSlice.js +65 -0
  71. package/dist/offlineSlice.js.map +1 -0
  72. package/dist/offlineSlice.test.d.ts +2 -0
  73. package/dist/offlineSlice.test.d.ts.map +1 -0
  74. package/dist/offlineSlice.test.js +153 -0
  75. package/dist/offlineSlice.test.js.map +1 -0
  76. package/dist/realtime.d.ts +123 -0
  77. package/dist/realtime.d.ts.map +1 -0
  78. package/dist/realtime.js +299 -0
  79. package/dist/realtime.js.map +1 -0
  80. package/dist/realtime.test.d.ts +2 -0
  81. package/dist/realtime.test.d.ts.map +1 -0
  82. package/dist/realtime.test.js +339 -0
  83. package/dist/realtime.test.js.map +1 -0
  84. package/dist/socket.d.ts.map +1 -1
  85. package/dist/socket.js +29 -28
  86. package/dist/socket.js.map +1 -1
  87. package/dist/sync.d.ts +35 -0
  88. package/dist/sync.d.ts.map +1 -0
  89. package/dist/sync.js +205 -0
  90. package/dist/sync.js.map +1 -0
  91. package/dist/tagGenerator.d.ts +20 -1
  92. package/dist/tagGenerator.d.ts.map +1 -1
  93. package/dist/tagGenerator.js +4 -12
  94. package/dist/tagGenerator.js.map +1 -1
  95. package/dist/useFeatureFlags.d.ts +4 -2
  96. package/dist/useFeatureFlags.d.ts.map +1 -1
  97. package/dist/useFeatureFlags.js +3 -5
  98. package/dist/useFeatureFlags.js.map +1 -1
  99. package/dist/useOfflineStatus.d.ts +33 -0
  100. package/dist/useOfflineStatus.d.ts.map +1 -0
  101. package/dist/useOfflineStatus.js +43 -0
  102. package/dist/useOfflineStatus.js.map +1 -0
  103. package/dist/useOfflineStatus.test.d.ts +2 -0
  104. package/dist/useOfflineStatus.test.d.ts.map +1 -0
  105. package/dist/useOfflineStatus.test.js +119 -0
  106. package/dist/useOfflineStatus.test.js.map +1 -0
  107. package/dist/useRealtimeDebug.d.ts +6 -0
  108. package/dist/useRealtimeDebug.d.ts.map +1 -0
  109. package/dist/useRealtimeDebug.js +41 -0
  110. package/dist/useRealtimeDebug.js.map +1 -0
  111. package/dist/useServerStatus.d.ts +29 -0
  112. package/dist/useServerStatus.d.ts.map +1 -0
  113. package/dist/useServerStatus.js +89 -0
  114. package/dist/useServerStatus.js.map +1 -0
  115. package/dist/useUpgradeCheck.d.ts +7 -3
  116. package/dist/useUpgradeCheck.d.ts.map +1 -1
  117. package/dist/useUpgradeCheck.js +27 -13
  118. package/dist/useUpgradeCheck.js.map +1 -1
  119. package/package.json +2 -2
  120. package/src/authSlice.test.ts +2 -11
  121. package/src/authSlice.ts +9 -9
  122. package/src/authSliceNative.test.ts +11 -4
  123. package/src/betterAuthSlice.test.ts +1 -25
  124. package/src/betterAuthSlice.ts +3 -2
  125. package/src/constants.test.ts +99 -1
  126. package/src/constants.ts +14 -3
  127. package/src/emptyApi.ts +11 -3
  128. package/src/index.ts +8 -0
  129. package/src/isolated/constants.isolated.ts +7 -3
  130. package/src/isolated/offlineMiddleware.isolated.ts +696 -0
  131. package/src/isolated/offlineMiddleware.native.isolated.ts +80 -0
  132. package/src/{useUpgradeCheck.mobile.test.ts → isolated/useUpgradeCheck.mobile.isolated.ts} +13 -4
  133. package/src/{useUpgradeCheck.test.ts → isolated/useUpgradeCheck.web.isolated.ts} +48 -6
  134. package/src/mongooseSlice.test.ts +1 -1
  135. package/src/mongooseSlice.ts +4 -5
  136. package/src/offlineGate.test.ts +55 -0
  137. package/src/offlineGate.ts +25 -0
  138. package/src/offlineMiddleware.test.ts +187 -0
  139. package/src/offlineMiddleware.ts +716 -0
  140. package/src/offlineSlice.test.ts +204 -0
  141. package/src/offlineSlice.ts +130 -0
  142. package/src/realtime.test.ts +404 -0
  143. package/src/realtime.ts +408 -0
  144. package/src/socket.ts +33 -32
  145. package/src/sync.ts +310 -0
  146. package/src/tagGenerator.ts +30 -18
  147. package/src/useFeatureFlags.ts +6 -6
  148. package/src/useOfflineStatus.test.ts +157 -0
  149. package/src/useOfflineStatus.ts +82 -0
  150. package/src/useRealtimeDebug.ts +51 -0
  151. package/src/useServerStatus.ts +122 -0
  152. package/src/useUpgradeCheck.ts +35 -13
  153. package/dist/isolated/useUpgradeCheck.isolated.d.ts +0 -2
  154. package/dist/isolated/useUpgradeCheck.isolated.d.ts.map +0 -1
  155. package/dist/isolated/useUpgradeCheck.isolated.js +0 -153
  156. package/dist/isolated/useUpgradeCheck.isolated.js.map +0 -1
  157. package/dist/useUpgradeCheck.mobile.test.d.ts +0 -2
  158. package/dist/useUpgradeCheck.mobile.test.d.ts.map +0 -1
  159. package/dist/useUpgradeCheck.mobile.test.js.map +0 -1
  160. package/dist/useUpgradeCheck.test.d.ts +0 -2
  161. package/dist/useUpgradeCheck.test.d.ts.map +0 -1
  162. package/dist/useUpgradeCheck.test.js.map +0 -1
  163. package/src/isolated/useUpgradeCheck.isolated.ts +0 -201
@@ -0,0 +1,716 @@
1
+ import {createListenerMiddleware, type Middleware} from "@reduxjs/toolkit";
2
+ import type {Api} from "@reduxjs/toolkit/query/react";
3
+ import {DateTime} from "luxon";
4
+
5
+ import {getAuthToken, selectCurrentUserId} from "./authSlice";
6
+ import {baseUrl, LOGOUT_ACTION_TYPE, type RootState} from "./constants";
7
+ import {configureOfflineMutationEndpoints} from "./offlineGate";
8
+ import {
9
+ addConflict,
10
+ type ConflictRecord,
11
+ clearConflicts,
12
+ clearQueue,
13
+ dequeue,
14
+ enqueue,
15
+ type OfflineState,
16
+ offlineReducer,
17
+ offlineSlice,
18
+ type QueuedMutation,
19
+ selectIsOnline,
20
+ selectIsSyncing,
21
+ selectOfflineQueue,
22
+ setOnlineStatus,
23
+ setSyncing,
24
+ } from "./offlineSlice";
25
+ import {IsWeb} from "./platform";
26
+
27
+ export interface OfflineMiddlewareConfig {
28
+ /** RTK Query mutation endpoint names to queue when offline */
29
+ endpoints: string[];
30
+ /** The RTK Query API instance */
31
+ // biome-ignore lint/suspicious/noExplicitAny: Generic API type
32
+ api: Api<any, any, any, any>;
33
+ }
34
+
35
+ /**
36
+ * Infer the CRUD operation type from an RTK Query endpoint name.
37
+ * Convention: postX = create, patchX = update, deleteX = delete.
38
+ */
39
+ const inferMutationType = (endpointName: string): "create" | "update" | "delete" => {
40
+ if (endpointName.startsWith("post")) {
41
+ return "create";
42
+ }
43
+ if (endpointName.startsWith("patch")) {
44
+ return "update";
45
+ }
46
+ if (endpointName.startsWith("delete")) {
47
+ return "delete";
48
+ }
49
+ return "update";
50
+ };
51
+
52
+ /**
53
+ * True when a rejected RTK Query action failed due to a network/transport error,
54
+ * not an auth or application-level FETCH_ERROR from emptyApi.
55
+ */
56
+ export const isNetworkFetchError = (
57
+ // biome-ignore lint/suspicious/noExplicitAny: RTK Query error shapes vary by source
58
+ source: any
59
+ ): boolean => {
60
+ if (source?.error?.name === "TypeError") {
61
+ return true;
62
+ }
63
+
64
+ const NETWORK_PATTERNS = [
65
+ "failed to fetch",
66
+ "fetch failed",
67
+ "network error",
68
+ "network unavailable",
69
+ "load failed",
70
+ ];
71
+
72
+ const candidates: string[] = [];
73
+ if (typeof source?.error?.message === "string") {
74
+ candidates.push(source.error.message);
75
+ }
76
+ if (typeof source?.error === "string") {
77
+ candidates.push(source.error);
78
+ }
79
+ if (typeof source?.payload?.error === "string") {
80
+ candidates.push(source.payload.error);
81
+ }
82
+ if (typeof source?.status === "string" && source.status === "FETCH_ERROR") {
83
+ if (typeof source?.error === "string") {
84
+ candidates.push(source.error);
85
+ }
86
+ }
87
+
88
+ const combined = candidates.join(" ").toLowerCase();
89
+ return NETWORK_PATTERNS.some((p) => combined.includes(p));
90
+ };
91
+
92
+ /**
93
+ * Whether a queued mutation may be replayed for the currently signed-in user.
94
+ * Legacy entries without userId are discarded to avoid cross-account replay after account switch.
95
+ */
96
+ export const shouldReplayQueuedMutation = (
97
+ mutation: QueuedMutation,
98
+ currentUserId: string | undefined
99
+ ): boolean => {
100
+ if (!currentUserId) {
101
+ return false;
102
+ }
103
+ if (mutation.userId === undefined) {
104
+ return false;
105
+ }
106
+ return mutation.userId === currentUserId;
107
+ };
108
+
109
+ /** Optimistic list item for a queued create; temp IDs are applied after body spread. */
110
+ export const buildOptimisticCreateItem = (
111
+ mutation: QueuedMutation,
112
+ body?: Record<string, unknown>
113
+ ): Record<string, unknown> => {
114
+ const tempId = `temp-${mutation.id}`;
115
+ return {
116
+ ...(body ?? {}),
117
+ _id: tempId,
118
+ created: mutation.timestamp,
119
+ id: tempId,
120
+ updated: mutation.timestamp,
121
+ };
122
+ };
123
+
124
+ /** Derives the collection tag from an endpoint name (e.g. "patchTodosById" → "todos"). */
125
+ const inferTagType = (endpointName: string): string => {
126
+ let name = endpointName.replace(/^(post|patch|delete)/, "").replace(/ById$/, "");
127
+ name = name.charAt(0).toLowerCase() + name.slice(1);
128
+ return name;
129
+ };
130
+
131
+ const inferGetByIdEndpointName = (endpointName: string): string => {
132
+ const tagType = inferTagType(endpointName);
133
+ const capitalized = tagType.charAt(0).toUpperCase() + tagType.slice(1);
134
+ return `get${capitalized}ById`;
135
+ };
136
+
137
+ /**
138
+ * Last-seen document `updated` timestamp for conflict headers on replay.
139
+ * Prefer the patch body's `_updatedAt`, then a cached get-by-id entry.
140
+ */
141
+ const extractBaseUpdatedAt = (
142
+ endpointName: string,
143
+ originalArgs: unknown,
144
+ // biome-ignore lint/suspicious/noExplicitAny: Generic getState
145
+ getState: () => any,
146
+ // biome-ignore lint/suspicious/noExplicitAny: Generic API type
147
+ api: Api<any, any, any, any>
148
+ ): string | undefined => {
149
+ if (inferMutationType(endpointName) !== "update") {
150
+ return undefined;
151
+ }
152
+
153
+ const args = originalArgs as {body?: Record<string, unknown>; id?: string};
154
+ const bodyUpdatedAt = args?.body?._updatedAt;
155
+ if (typeof bodyUpdatedAt === "string") {
156
+ return bodyUpdatedAt;
157
+ }
158
+
159
+ if (!args?.id) {
160
+ return undefined;
161
+ }
162
+
163
+ const getByIdEndpoint = inferGetByIdEndpointName(endpointName);
164
+ const state = getState();
165
+ // biome-ignore lint/suspicious/noExplicitAny: RTK Query internal state shape
166
+ const queries: Record<string, any> = state[api.reducerPath]?.queries ?? {};
167
+ for (const key of Object.keys(queries)) {
168
+ if (!key.startsWith(`${getByIdEndpoint}(`)) {
169
+ continue;
170
+ }
171
+ const queryEntry = queries[key];
172
+ if (queryEntry?.originalArgs?.id !== args.id) {
173
+ continue;
174
+ }
175
+ const data = queryEntry?.data as {data?: {updated?: string}} | undefined;
176
+ const updated = data?.data?.updated;
177
+ if (typeof updated === "string") {
178
+ return updated;
179
+ }
180
+ }
181
+
182
+ return undefined;
183
+ };
184
+
185
+ /**
186
+ * Collect all cached query args for a given list endpoint so optimistic
187
+ * updates are applied to every active cache entry (regardless of the
188
+ * args the consumer passed to the query hook, e.g. `{}` vs `undefined`).
189
+ */
190
+ const getCachedQueryArgs = (
191
+ // biome-ignore lint/suspicious/noExplicitAny: Generic API state shape
192
+ getState: () => any,
193
+ // biome-ignore lint/suspicious/noExplicitAny: Generic API type
194
+ api: Api<any, any, any, any>,
195
+ listEndpointName: string
196
+ ): unknown[] => {
197
+ const state = getState();
198
+ // biome-ignore lint/suspicious/noExplicitAny: RTK Query internal state shape
199
+ const queries: Record<string, any> = state[api.reducerPath]?.queries ?? {};
200
+ const cachedArgs: unknown[] = [];
201
+ for (const key of Object.keys(queries)) {
202
+ if (key.startsWith(`${listEndpointName}(`)) {
203
+ cachedArgs.push(queries[key]?.originalArgs);
204
+ }
205
+ }
206
+ if (cachedArgs.length === 0) {
207
+ cachedArgs.push(undefined);
208
+ }
209
+ return cachedArgs;
210
+ };
211
+
212
+ /**
213
+ * Apply an optimistic update to the RTK Query cache for a queued mutation.
214
+ */
215
+ const applyOptimisticUpdate = (
216
+ // biome-ignore lint/suspicious/noExplicitAny: Generic API type
217
+ api: Api<any, any, any, any>,
218
+ // biome-ignore lint/suspicious/noExplicitAny: Generic dispatch
219
+ dispatch: any,
220
+ // biome-ignore lint/suspicious/noExplicitAny: Generic getState
221
+ getState: () => any,
222
+ mutation: QueuedMutation
223
+ ): void => {
224
+ const tagType = inferTagType(mutation.endpointName);
225
+ const listEndpointName = `get${tagType.charAt(0).toUpperCase() + tagType.slice(1)}`;
226
+ const cachedArgs = getCachedQueryArgs(getState, api, listEndpointName);
227
+
228
+ // biome-ignore lint/suspicious/noExplicitAny: RTK Query cache shape varies by endpoint
229
+ const updateAllCacheEntries = (updater: (draft: any) => void): void => {
230
+ for (const queryArg of cachedArgs) {
231
+ dispatch(
232
+ // biome-ignore lint/suspicious/noExplicitAny: RTK Query cache shape varies by endpoint
233
+ api.util.updateQueryData(listEndpointName as any, queryArg, updater)
234
+ );
235
+ }
236
+ };
237
+
238
+ if (mutation.type === "create") {
239
+ const args = mutation.args as {body?: Record<string, unknown>};
240
+ const tempItem = buildOptimisticCreateItem(mutation, args?.body);
241
+ // biome-ignore lint/suspicious/noExplicitAny: RTK Query cache shape varies by endpoint
242
+ updateAllCacheEntries((draft: any) => {
243
+ if (draft?.data && Array.isArray(draft.data)) {
244
+ draft.data.unshift(tempItem);
245
+ }
246
+ });
247
+ } else if (mutation.type === "update") {
248
+ const args = mutation.args as {id?: string; body?: Record<string, unknown>};
249
+ if (args?.id && args?.body) {
250
+ // biome-ignore lint/suspicious/noExplicitAny: RTK Query cache shape varies by endpoint
251
+ updateAllCacheEntries((draft: any) => {
252
+ if (draft?.data && Array.isArray(draft.data)) {
253
+ const item = draft.data.find(
254
+ (d: Record<string, unknown>) => d._id === args.id || d.id === args.id
255
+ );
256
+ if (item) {
257
+ Object.assign(item, args.body);
258
+ }
259
+ }
260
+ });
261
+ }
262
+ } else if (mutation.type === "delete") {
263
+ const args = mutation.args as {id?: string};
264
+ if (args?.id) {
265
+ // biome-ignore lint/suspicious/noExplicitAny: RTK Query cache shape varies by endpoint
266
+ updateAllCacheEntries((draft: any) => {
267
+ if (draft?.data && Array.isArray(draft.data)) {
268
+ draft.data = draft.data.filter(
269
+ (d: Record<string, unknown>) => d._id !== args.id && d.id !== args.id
270
+ );
271
+ }
272
+ });
273
+ }
274
+ }
275
+ };
276
+
277
+ /**
278
+ * Remove optimistic temp items from the cache after they've been replayed.
279
+ */
280
+ const removeTempItems = (
281
+ // biome-ignore lint/suspicious/noExplicitAny: Generic API type
282
+ api: Api<any, any, any, any>,
283
+ // biome-ignore lint/suspicious/noExplicitAny: Generic dispatch
284
+ dispatch: any,
285
+ // biome-ignore lint/suspicious/noExplicitAny: Generic getState
286
+ getState: () => any,
287
+ mutations: QueuedMutation[]
288
+ ): void => {
289
+ const tempIds = new Set(mutations.map((m) => `temp-${m.id}`));
290
+ const tagTypes = [...new Set(mutations.map((m) => inferTagType(m.endpointName)))];
291
+
292
+ for (const tagType of tagTypes) {
293
+ const listEndpointName = `get${tagType.charAt(0).toUpperCase() + tagType.slice(1)}`;
294
+ const cachedArgs = getCachedQueryArgs(getState, api, listEndpointName);
295
+
296
+ for (const queryArg of cachedArgs) {
297
+ dispatch(
298
+ // biome-ignore lint/suspicious/noExplicitAny: RTK Query cache shape varies by endpoint
299
+ api.util.updateQueryData(listEndpointName as any, queryArg, (draft: any) => {
300
+ if (draft?.data && Array.isArray(draft.data)) {
301
+ draft.data = draft.data.filter(
302
+ (d: Record<string, unknown>) =>
303
+ !tempIds.has(d._id as string) && !tempIds.has(d.id as string)
304
+ );
305
+ }
306
+ })
307
+ );
308
+ }
309
+ }
310
+ };
311
+
312
+ /**
313
+ * Replay a single queued mutation by making a direct API call.
314
+ * Returns the response status and data for conflict handling.
315
+ */
316
+ const replayMutation = async (
317
+ mutation: QueuedMutation
318
+ ): Promise<{status: number; data?: unknown}> => {
319
+ const token = await getAuthToken();
320
+ const headers: Record<string, string> = {
321
+ "Content-Type": "application/json",
322
+ };
323
+ if (token) {
324
+ headers.authorization = `Bearer ${token}`;
325
+ }
326
+
327
+ // For update operations, include If-Unmodified-Since from the document version at queue time
328
+ if (mutation.type === "update" && mutation.baseUpdatedAt) {
329
+ const timestamp = DateTime.fromISO(mutation.baseUpdatedAt);
330
+ if (timestamp.isValid) {
331
+ headers["If-Unmodified-Since"] = timestamp.toHTTP();
332
+ headers["X-Unmodified-Since-ISO"] = mutation.baseUpdatedAt;
333
+ }
334
+ }
335
+
336
+ const args = mutation.args as {id?: string; body?: unknown};
337
+ const tagType = inferTagType(mutation.endpointName);
338
+ const basePath = `/${tagType}`;
339
+
340
+ let url: string;
341
+ let method: string;
342
+ let body: string | undefined;
343
+
344
+ if (mutation.type === "create") {
345
+ url = `${baseUrl}${basePath}`;
346
+ method = "POST";
347
+ body = JSON.stringify(args?.body);
348
+ } else if (mutation.type === "update") {
349
+ url = `${baseUrl}${basePath}/${args?.id}`;
350
+ method = "PATCH";
351
+ body = JSON.stringify(args?.body);
352
+ } else {
353
+ url = `${baseUrl}${basePath}/${args?.id}`;
354
+ method = "DELETE";
355
+ }
356
+
357
+ const response = await fetch(url, {body, headers, method});
358
+
359
+ if (response.status === 204) {
360
+ return {status: 204};
361
+ }
362
+
363
+ const data = await response.json();
364
+ return {data, status: response.status};
365
+ };
366
+
367
+ /**
368
+ * Set up native-only network monitoring (expo-network).
369
+ * Web apps should use `useServerStatus` for server-level health checking.
370
+ */
371
+ const setupNativeNetworkMonitoring = (
372
+ // biome-ignore lint/suspicious/noExplicitAny: Generic dispatch
373
+ dispatch: any
374
+ ): (() => void) => {
375
+ if (IsWeb) {
376
+ return () => {};
377
+ }
378
+
379
+ let cleanup = (): void => {};
380
+ void import("expo-network").then((Network) => {
381
+ Network.getNetworkStateAsync().then((state) => {
382
+ dispatch(setOnlineStatus(state.isConnected ?? true));
383
+ });
384
+ const subscription = Network.addNetworkStateListener((state) => {
385
+ dispatch(setOnlineStatus(state.isConnected ?? true));
386
+ });
387
+ cleanup = () => subscription.remove();
388
+ });
389
+
390
+ return () => cleanup();
391
+ };
392
+
393
+ /**
394
+ * Creates an offline middleware system for RTK Query mutations.
395
+ *
396
+ * When the device is offline, configured mutation endpoints are queued instead of
397
+ * failing. When connectivity returns, queued mutations are replayed in order with
398
+ * LWW (Last-Writer-Wins) conflict detection via If-Unmodified-Since headers.
399
+ *
400
+ * Usage:
401
+ * ```typescript
402
+ * const offline = createOfflineMiddleware({
403
+ * endpoints: ["postTodos", "patchTodosById", "deleteTodosById"],
404
+ * api: terrenoApi,
405
+ * });
406
+ *
407
+ * // Add to store:
408
+ * // reducer: { offline: offline.offlineReducer }
409
+ * // middleware: [..., offline.middleware]
410
+ * ```
411
+ */
412
+ export const createOfflineMiddleware = (
413
+ config: OfflineMiddlewareConfig
414
+ ): {
415
+ middleware: Middleware;
416
+ offlineReducer: typeof offlineReducer;
417
+ offlineSlice: typeof offlineSlice;
418
+ } => {
419
+ const {endpoints, api} = config;
420
+ const endpointSet = new Set(endpoints);
421
+ configureOfflineMutationEndpoints(endpoints);
422
+
423
+ const listenerMiddleware = createListenerMiddleware();
424
+ let _networkCleanup: (() => void) | undefined;
425
+ let isReplayInProgress = false;
426
+
427
+ // Listener 1: Set up native network monitoring on first action (lazy init)
428
+ let networkInitialized = false;
429
+ listenerMiddleware.startListening({
430
+ effect: (_action, listenerApi) => {
431
+ networkInitialized = true;
432
+ _networkCleanup = setupNativeNetworkMonitoring(listenerApi.dispatch);
433
+ },
434
+ predicate: () => !networkInitialized,
435
+ });
436
+
437
+ // Listener 2: Intercept failed mutations on network errors (offline or server unreachable)
438
+ // RTK Query dispatches actions with type "terreno-rtk/executeMutation/rejected"
439
+ // when a mutation fails. Queue the mutation and apply an optimistic cache update.
440
+ listenerMiddleware.startListening({
441
+ // biome-ignore lint/suspicious/noExplicitAny: RTK Query action types are complex
442
+ effect: async (action: any, listenerApi) => {
443
+ const state = listenerApi.getState() as {offline: OfflineState};
444
+ const isOnline = selectIsOnline(state);
445
+
446
+ if (!isNetworkFetchError(action)) {
447
+ return;
448
+ }
449
+
450
+ const endpointName = action.meta.arg.endpointName as string;
451
+ const originalArgs = action.meta.arg.originalArgs;
452
+
453
+ const mutationType = inferMutationType(endpointName);
454
+
455
+ // For updates, use the document's last-known `updated` timestamp from cache
456
+ // rather than the current time. This ensures conflict detection compares against
457
+ // when the document was last fetched, not when the mutation was queued.
458
+ let timestamp = DateTime.now().toISO();
459
+ let listCacheBaseUpdatedAt: string | undefined;
460
+ if (mutationType === "update") {
461
+ const args = originalArgs as {id?: string};
462
+ if (args?.id) {
463
+ const tagType = inferTagType(endpointName);
464
+ const listEndpointName = `get${tagType.charAt(0).toUpperCase() + tagType.slice(1)}`;
465
+ const cachedArgs = getCachedQueryArgs(listenerApi.getState, api, listEndpointName);
466
+ for (const queryArg of cachedArgs) {
467
+ // biome-ignore lint/suspicious/noExplicitAny: RTK Query cache shape and endpoint types vary
468
+ const endpoint = (api.endpoints as any)[listEndpointName];
469
+ if (!endpoint?.select) {
470
+ continue;
471
+ }
472
+ // biome-ignore lint/suspicious/noExplicitAny: RTK Query cache shape varies
473
+ const cacheEntry = endpoint.select(queryArg)(listenerApi.getState() as any) as any;
474
+ const items = cacheEntry?.data?.data;
475
+ if (Array.isArray(items)) {
476
+ const doc = items.find(
477
+ (d: Record<string, unknown>) => d._id === args.id || d.id === args.id
478
+ );
479
+ if (doc?.updated) {
480
+ timestamp =
481
+ typeof doc.updated === "string"
482
+ ? doc.updated
483
+ : DateTime.fromJSDate(doc.updated).toISO();
484
+ listCacheBaseUpdatedAt = timestamp ?? undefined;
485
+ break;
486
+ }
487
+ }
488
+ }
489
+ }
490
+ }
491
+
492
+ const baseUpdatedAt =
493
+ extractBaseUpdatedAt(endpointName, originalArgs, listenerApi.getState, api) ??
494
+ listCacheBaseUpdatedAt;
495
+
496
+ const currentUserId = selectCurrentUserId(listenerApi.getState() as RootState);
497
+
498
+ const mutation: QueuedMutation = {
499
+ args: originalArgs,
500
+ baseUpdatedAt,
501
+ endpointName,
502
+ id: `${endpointName}-${DateTime.now().toMillis()}-${Math.random().toString(36).slice(2, 8)}`,
503
+ timestamp,
504
+ type: mutationType,
505
+ userId: currentUserId,
506
+ };
507
+
508
+ listenerApi.dispatch(enqueue(mutation));
509
+ applyOptimisticUpdate(api, listenerApi.dispatch, listenerApi.getState, mutation);
510
+
511
+ // Browser may still report online when the API server is unreachable
512
+ if (isOnline) {
513
+ listenerApi.dispatch(setOnlineStatus(false));
514
+ }
515
+ },
516
+ // biome-ignore lint/suspicious/noExplicitAny: RTK Query internal action shape
517
+ predicate: (action: any) => {
518
+ if (typeof action?.type !== "string") {
519
+ return false;
520
+ }
521
+ // Match RTK Query mutation rejected actions
522
+ return (
523
+ action.type.includes("/executeMutation/rejected") &&
524
+ action?.meta?.arg?.endpointName &&
525
+ endpointSet.has(action.meta.arg.endpointName)
526
+ );
527
+ },
528
+ });
529
+
530
+ // Listener 3: Mark offline when any API request fails with a network error
531
+ listenerMiddleware.startListening({
532
+ effect: (_action, listenerApi) => {
533
+ const state = listenerApi.getState() as {offline: OfflineState};
534
+ if (selectIsOnline(state)) {
535
+ listenerApi.dispatch(setOnlineStatus(false));
536
+ }
537
+ },
538
+ // biome-ignore lint/suspicious/noExplicitAny: RTK Query internal action shape
539
+ predicate: (action: any) => {
540
+ if (typeof action?.type !== "string") {
541
+ return false;
542
+ }
543
+ return (
544
+ action.type.startsWith(`${api.reducerPath}/`) &&
545
+ (action.type.includes("/executeQuery/rejected") ||
546
+ action.type.includes("/executeMutation/rejected")) &&
547
+ isNetworkFetchError(action)
548
+ );
549
+ },
550
+ });
551
+
552
+ // Listener 4: Mark online when any API request succeeds (server reachable again)
553
+ listenerMiddleware.startListening({
554
+ effect: (_action, listenerApi) => {
555
+ const state = listenerApi.getState() as {offline: OfflineState};
556
+ if (!selectIsOnline(state)) {
557
+ listenerApi.dispatch(setOnlineStatus(true));
558
+ }
559
+ },
560
+ // biome-ignore lint/suspicious/noExplicitAny: RTK Query internal action shape
561
+ predicate: (action: any) => {
562
+ if (typeof action?.type !== "string") {
563
+ return false;
564
+ }
565
+ return (
566
+ action.type.startsWith(`${api.reducerPath}/`) &&
567
+ action.type.endsWith("/fulfilled") &&
568
+ (action.type.includes("/executeQuery/") || action.type.includes("/executeMutation/"))
569
+ );
570
+ },
571
+ });
572
+
573
+ // Listener 5: Sync queue when coming back online
574
+ listenerMiddleware.startListening({
575
+ actionCreator: setOnlineStatus,
576
+ effect: async (action, listenerApi) => {
577
+ if (!action.payload) {
578
+ // Going offline, nothing to do
579
+ return;
580
+ }
581
+
582
+ if (isReplayInProgress) {
583
+ return;
584
+ }
585
+ listenerApi.cancelActiveListeners();
586
+
587
+ const state = listenerApi.getState() as {offline: OfflineState};
588
+ const queue = selectOfflineQueue(state);
589
+ const currentUserId = selectCurrentUserId(listenerApi.getState() as RootState);
590
+
591
+ if (queue.length === 0) {
592
+ // Reset isSyncing if it was stuck from a crash/reload during sync
593
+ if (selectIsSyncing(state)) {
594
+ listenerApi.dispatch(setSyncing(false));
595
+ }
596
+ return;
597
+ }
598
+
599
+ isReplayInProgress = true;
600
+ listenerApi.dispatch(setSyncing(true));
601
+
602
+ const replayedCreates: QueuedMutation[] = [];
603
+
604
+ try {
605
+ // Replay mutations in FIFO order
606
+ for (const mutation of queue) {
607
+ if (!shouldReplayQueuedMutation(mutation, currentUserId)) {
608
+ console.warn(
609
+ `[offline] Dropping queued mutation ${mutation.endpointName} — not owned by current user`
610
+ );
611
+ listenerApi.dispatch(dequeue(mutation.id));
612
+ continue;
613
+ }
614
+
615
+ try {
616
+ const result = await replayMutation(mutation);
617
+
618
+ if (result.status === 409) {
619
+ const conflict: ConflictRecord = {
620
+ args: mutation.args,
621
+ dismissed: false,
622
+ endpointName: mutation.endpointName,
623
+ id: mutation.id,
624
+ serverDocument: (result.data as {data?: unknown})?.data ?? result.data,
625
+ timestamp: DateTime.now().toISO(),
626
+ };
627
+ listenerApi.dispatch(addConflict(conflict));
628
+ listenerApi.dispatch(dequeue(mutation.id));
629
+ if (mutation.type === "create") {
630
+ replayedCreates.push(mutation);
631
+ }
632
+ } else if (result.status >= 200 && result.status < 300) {
633
+ listenerApi.dispatch(dequeue(mutation.id));
634
+ if (mutation.type === "create") {
635
+ replayedCreates.push(mutation);
636
+ }
637
+ } else {
638
+ console.warn(
639
+ `[offline] Replay failed for ${mutation.endpointName}: status ${result.status}`
640
+ );
641
+ if (result.status >= 400 && result.status < 500 && result.status !== 409) {
642
+ listenerApi.dispatch(dequeue(mutation.id));
643
+ if (mutation.type === "create") {
644
+ replayedCreates.push(mutation);
645
+ }
646
+ }
647
+ }
648
+ } catch (_error) {
649
+ console.warn(`[offline] Replay network error for ${mutation.endpointName}`);
650
+ break;
651
+ }
652
+ }
653
+
654
+ // Remove optimistic temp items for creates that were replayed
655
+ if (replayedCreates.length > 0) {
656
+ removeTempItems(api, listenerApi.dispatch, listenerApi.getState, replayedCreates);
657
+ }
658
+
659
+ // Invalidate tags to refresh cache with server state
660
+ const tagTypes = [...new Set(queue.map((m) => inferTagType(m.endpointName)))];
661
+ for (const tagType of tagTypes) {
662
+ listenerApi.dispatch(api.util.invalidateTags([tagType]));
663
+ }
664
+ } finally {
665
+ isReplayInProgress = false;
666
+ listenerApi.dispatch(setSyncing(false));
667
+ }
668
+ },
669
+ });
670
+
671
+ // Listener 6: Clear persisted queue on logout so mutations are not replayed for the next user
672
+ listenerMiddleware.startListening({
673
+ effect: (_action, listenerApi) => {
674
+ listenerApi.dispatch(clearQueue());
675
+ listenerApi.dispatch(clearConflicts());
676
+ },
677
+ type: LOGOUT_ACTION_TYPE,
678
+ });
679
+
680
+ // Listener 7: On startup, if the persisted queue has items and we're online,
681
+ // trigger a replay. This handles the case where the app reloads while online
682
+ // with a non-empty queue (rehydrated state already has isOnline: true, so no
683
+ // offline→online transition would otherwise occur).
684
+ let startupReplayTriggered = false;
685
+ listenerMiddleware.startListening({
686
+ effect: (_action, listenerApi) => {
687
+ startupReplayTriggered = true;
688
+ const state = listenerApi.getState() as {offline: OfflineState};
689
+ const queue = selectOfflineQueue(state);
690
+ const isOnline = selectIsOnline(state);
691
+
692
+ // Reset stuck isSyncing from a crash during prior sync
693
+ if (selectIsSyncing(state) && !isReplayInProgress) {
694
+ listenerApi.dispatch(setSyncing(false));
695
+ }
696
+
697
+ if (queue.length > 0 && isOnline && !isReplayInProgress) {
698
+ // Re-dispatch setOnlineStatus(true) to trigger the sync listener
699
+ listenerApi.dispatch(setOnlineStatus(true));
700
+ }
701
+ },
702
+ predicate: (action) => {
703
+ if (startupReplayTriggered) {
704
+ return false;
705
+ }
706
+ // redux-persist dispatches "persist/REHYDRATE" after loading stored state
707
+ return typeof action?.type === "string" && action.type === "persist/REHYDRATE";
708
+ },
709
+ });
710
+
711
+ return {
712
+ middleware: listenerMiddleware.middleware,
713
+ offlineReducer,
714
+ offlineSlice,
715
+ };
716
+ };