Skip to content

Commit fdb5ae2

Browse files
fix(mobile): harden offline mutation queue
1 parent 589436f commit fdb5ae2

4 files changed

Lines changed: 116 additions & 29 deletions

File tree

apps/mobile/app/services/api.ts

Lines changed: 68 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as Sentry from 'sentry-expo';
55
import { ApiError } from '../types/api-error';
66
import { offlineQueue } from '../utils/offlineQueue';
77
import { trackApiLatency } from '../utils/perf';
8+
import { generateIdempotencyKey } from './idempotency';
89

910
type ExpoConstantsDevHostShape = {
1011
manifest2?: {
@@ -28,13 +29,66 @@ function readIdempotencyHeader(headers: unknown): string | undefined {
2829
return typeof value === 'string' ? value : undefined;
2930
}
3031

32+
function buildQueueableRequest(
33+
method: string,
34+
url: string,
35+
data: unknown,
36+
idempotencyKey?: string,
37+
): {
38+
method: 'POST' | 'PATCH' | 'DELETE';
39+
url: string;
40+
data?: unknown;
41+
headers?: Record<string, string>;
42+
} | null {
43+
const normalizedMethod = method.toUpperCase();
44+
45+
if (
46+
normalizedMethod === 'POST' &&
47+
((url.includes('/groups/') && url.includes('/expenses')) ||
48+
url.includes('/settlements') ||
49+
url.includes('/invite'))
50+
) {
51+
return {
52+
method: 'POST',
53+
url,
54+
data,
55+
headers: { 'x-idempotency-key': idempotencyKey ?? generateIdempotencyKey('mutation') },
56+
};
57+
}
58+
59+
if (
60+
normalizedMethod === 'PATCH' &&
61+
(url.includes('/expenses/') || url.includes('/recurring-expenses/'))
62+
) {
63+
return {
64+
method: 'PATCH',
65+
url,
66+
data,
67+
};
68+
}
69+
70+
if (
71+
normalizedMethod === 'DELETE' &&
72+
(url.includes('/expenses/') || url.includes('/recurring-expenses/'))
73+
) {
74+
return {
75+
method: 'DELETE',
76+
url,
77+
headers: idempotencyKey ? { 'x-idempotency-key': idempotencyKey } : undefined,
78+
};
79+
}
80+
81+
return null;
82+
}
83+
3184
function resolveApiBaseUrl(): string {
3285
if (process.env.EXPO_PUBLIC_API_URL) {
3386
return process.env.EXPO_PUBLIC_API_URL;
3487
}
3588

3689
const constants = Constants as unknown as ExpoConstantsDevHostShape;
37-
const hostUri = constants.manifest2?.extra?.expoClient?.hostUri ?? constants.manifest?.debuggerHost;
90+
const hostUri =
91+
constants.manifest2?.extra?.expoClient?.hostUri ?? constants.manifest?.debuggerHost;
3892
const host = hostUri?.split(':')[0];
3993

4094
if (host && !host.endsWith('.exp.direct')) {
@@ -90,7 +144,8 @@ api.interceptors.request.use(async (config) => {
90144

91145
api.interceptors.response.use(
92146
(response) => {
93-
const startTs = (response.config as typeof response.config & { metadata?: { startTs: number } }).metadata?.startTs;
147+
const startTs = (response.config as typeof response.config & { metadata?: { startTs: number } })
148+
.metadata?.startTs;
94149
if (startTs) {
95150
trackApiLatency(response.config.url, response.config.method, Date.now() - startTs);
96151
}
@@ -100,21 +155,16 @@ api.interceptors.response.use(
100155
return response;
101156
},
102157
(error) => {
103-
const startTs = (error?.config as { metadata?: { startTs: number } } | undefined)?.metadata?.startTs;
158+
const startTs = (error?.config as { metadata?: { startTs: number } } | undefined)?.metadata
159+
?.startTs;
104160
if (startTs) {
105161
trackApiLatency(error?.config?.url, error?.config?.method, Date.now() - startTs);
106162
}
107163

108164
const method = String(error?.config?.method ?? '').toUpperCase();
109165
const url = String(error?.config?.url ?? '');
110166
const retryMarked = error?.config?.headers?.['x-offline-retry'] === '1';
111-
const shouldQueue =
112-
!retryMarked &&
113-
method === 'POST' &&
114-
(url.includes('/groups/') && url.includes('/expenses') ||
115-
url.includes('/settlements') ||
116-
url.includes('/invite')) &&
117-
!error?.response;
167+
const shouldQueue = !retryMarked && !error?.response;
118168

119169
if (shouldQueue) {
120170
let data: unknown = error?.config?.data;
@@ -127,21 +177,21 @@ api.interceptors.response.use(
127177
}
128178
}
129179

130-
void offlineQueue.enqueue({
131-
id: `${Date.now()}_${Math.random().toString(36).slice(2)}`,
132-
method: 'POST',
133-
url,
134-
data,
135-
headers: idempotencyKey ? { 'x-idempotency-key': idempotencyKey } : undefined,
136-
});
180+
const queuedRequest = buildQueueableRequest(method, url, data, idempotencyKey);
181+
if (queuedRequest) {
182+
void offlineQueue.enqueue({
183+
id: `${Date.now()}_${Math.random().toString(36).slice(2)}`,
184+
...queuedRequest,
185+
});
186+
}
137187
}
138188

139189
const wrapped: ApiError = {
140190
code: String(error?.response?.status ?? 'NETWORK_ERROR'),
141191
message:
142192
(error?.response?.data?.message as string | undefined) ??
143193
(typeof error?.message === 'string' ? error.message : 'Request failed') +
144-
(shouldQueue ? ' (queued for retry)' : ''),
194+
(shouldQueue ? ' (queued for retry when supported)' : ''),
145195
context: {
146196
status: error?.response?.status,
147197
url: error?.config?.url,

apps/mobile/app/services/group.service.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,28 @@ import {
1010
type UpdateGroupDefaultSplitRequestDto,
1111
} from '@fairshare/shared-types';
1212
import { api } from './api';
13+
import { generateIdempotencyKey } from './idempotency';
1314

1415
export const groupService = {
15-
create: async (payload: CreateGroupRequestDto) => (await api.post<GroupDto>('/groups', payload)).data,
16+
create: async (payload: CreateGroupRequestDto) =>
17+
(
18+
await api.post<GroupDto>('/groups', payload, {
19+
headers: { 'x-idempotency-key': generateIdempotencyKey('mutation') },
20+
})
21+
).data,
1622
list: async () => (await api.get<GroupDto[]>('/groups')).data,
1723
get: async (id: string) => (await api.get<GroupDto>(`/groups/${id}`)).data,
18-
members: async (id: string) => (await api.get<GroupMemberSummaryDto[]>(`/groups/${id}/members`)).data,
24+
members: async (id: string) =>
25+
(await api.get<GroupMemberSummaryDto[]>(`/groups/${id}/members`)).data,
1926
summary: async (id: string) => (await api.get<GroupSummaryDto>(`/groups/${id}/summary`)).data,
2027
updateDefaultSplit: async (id: string, payload: UpdateGroupDefaultSplitRequestDto) =>
2128
(await api.patch<GroupDto>(`/groups/${id}/default-split`, payload)).data,
22-
invite: async (id: string, payload: InviteMemberRequestDto) => (await api.post(`/groups/${id}/invite`, payload)).data,
29+
invite: async (id: string, payload: InviteMemberRequestDto) =>
30+
(
31+
await api.post(`/groups/${id}/invite`, payload, {
32+
headers: { 'x-idempotency-key': generateIdempotencyKey('mutation') },
33+
})
34+
).data,
2335
remindSettlement: async (id: string, payload: RemindSettlementRequestDto) =>
2436
(await api.post<RemindSettlementResponseDto>(`/groups/${id}/remind-settlement`, payload)).data,
2537
balances: async (id: string) => (await api.get(`/groups/${id}/balances`)).data,

apps/mobile/app/services/idempotency.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ function createFallbackId(): string {
22
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
33
}
44

5-
export function generateIdempotencyKey(scope: 'expense' | 'settlement'): string {
6-
const cryptoApi = (globalThis as typeof globalThis & { crypto?: { randomUUID?: () => string } }).crypto;
7-
const randomId = typeof cryptoApi?.randomUUID === 'function' ? cryptoApi.randomUUID() : createFallbackId();
5+
export function generateIdempotencyKey(scope: 'expense' | 'settlement' | 'mutation'): string {
6+
const cryptoApi = (globalThis as typeof globalThis & { crypto?: { randomUUID?: () => string } })
7+
.crypto;
8+
const randomId =
9+
typeof cryptoApi?.randomUUID === 'function' ? cryptoApi.randomUUID() : createFallbackId();
810

911
return `mobile:${scope}:${randomId}`;
1012
}

apps/mobile/app/utils/offlineQueue.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,29 @@
11
import NetInfo from '@react-native-community/netinfo';
22
import * as SecureStore from 'expo-secure-store';
33

4-
type OfflineRequest = {
4+
type OfflineMethod = 'POST' | 'PATCH' | 'DELETE';
5+
6+
export type OfflineRequest = {
57
id: string;
6-
method: 'POST';
8+
method: OfflineMethod;
79
url: string;
8-
data: unknown;
10+
data?: unknown;
911
headers?: Record<string, string>;
1012
};
1113

1214
type OfflineRequestExecutor = (request: OfflineRequest) => Promise<void>;
1315

1416
const STORAGE_KEY = 'fairshare_offline_queue';
1517

18+
function getIdempotencyKey(request: OfflineRequest): string | undefined {
19+
const value = request.headers?.['x-idempotency-key'];
20+
return typeof value === 'string' && value.length > 0 ? value : undefined;
21+
}
22+
23+
function getQueueIdentity(request: OfflineRequest): string {
24+
return getIdempotencyKey(request) ?? `${request.method}:${request.url}`;
25+
}
26+
1627
class OfflineQueue {
1728
private initialized = false;
1829
private flushing = false;
@@ -22,9 +33,21 @@ class OfflineQueue {
2233
this.requestExecutor = executor;
2334
}
2435

25-
async enqueue(req: OfflineRequest): Promise<void> {
36+
async enqueue(request: OfflineRequest): Promise<void> {
2637
const queue = await this.readQueue();
27-
queue.push(req);
38+
const identity = getQueueIdentity(request);
39+
const existingIndex = queue.findIndex((item) => getQueueIdentity(item) === identity);
40+
41+
if (existingIndex >= 0) {
42+
queue[existingIndex] = {
43+
...queue[existingIndex],
44+
...request,
45+
id: queue[existingIndex].id,
46+
};
47+
} else {
48+
queue.push(request);
49+
}
50+
2851
await this.writeQueue(queue);
2952
}
3053

0 commit comments

Comments
 (0)