Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/constants/app.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,20 @@ export const SYNC_BACKOFF_CAP_MS = 10000;
export const SYNC_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
/** Retention window (ms) for dead-lettered operations before GC (30 days). */
export const DEAD_LETTER_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
/**
* Hard cap on retained acknowledged operations.
*
* Age alone does not bound the store: a device that syncs thousands of
* operations inside the retention window keeps every one of them. The cap
* evicts oldest-first once it is exceeded.
*/
export const SYNC_MAX_ACKED_RECORDS = 5000;
/** Hard cap on retained dead-letter records, evicted oldest-first. */
export const DEAD_LETTER_MAX_RECORDS = 500;
/** How often the background retention sweep runs (6 hours). */
export const SYNC_RETENTION_SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000;
/** Retention window (ms) for persisted store slices before GC (30 days). */
export const PERSISTED_STATE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
/** Background sync tag used by the service worker to trigger a drain. */
export const SYNC_BACKGROUND_TAG = 'teachlink-offline-sync';

Expand Down
165 changes: 165 additions & 0 deletions src/hooks/__tests__/useOfflineSync.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import { useOfflineSync, type OfflineSyncStatusSnapshot } from '../useOfflineSync';

const status = (overrides: Partial<OfflineSyncStatusSnapshot> = {}): OfflineSyncStatusSnapshot => ({
pending: 0,
conflicted: 0,
resolved: 0,
deadLetter: 0,
...overrides,
});

const summary = (count: number, oldestFailedAt: string | null = null) => ({
count,
byType: count > 0 ? { course_progress: count } : {},
oldestFailedAt,
});

beforeEach(() => {
vi.restoreAllMocks();
});

describe('useOfflineSync dead-letter surfacing', () => {
it('starts with an empty dead-letter state', () => {
const { result } = renderHook(() => useOfflineSync());

expect(result.current.deadLetterCount).toBe(0);
expect(result.current.hasDeadLetter).toBe(false);
expect(result.current.deadLetter).toEqual({ count: 0, byType: {}, oldestFailedAt: null });
});

// A user arriving with a stuck queue has not triggered a sync yet, so
// waiting for the next drain to read the queue means they never see it.
it('reads the dead-letter queue on mount, without a sync', async () => {
const getDeadLetterSummary = vi.fn().mockResolvedValue(summary(3, '2026-01-02T00:00:00.000Z'));

const { result } = renderHook(() =>
useOfflineSync(undefined, undefined, { getDeadLetterSummary }),
);

await waitFor(() => expect(result.current.deadLetterCount).toBe(3));
expect(getDeadLetterSummary).toHaveBeenCalledTimes(1);
expect(result.current.hasDeadLetter).toBe(true);
expect(result.current.deadLetter.oldestFailedAt).toBe('2026-01-02T00:00:00.000Z');
});

it('reads the conflict status on mount too', async () => {
const getStatus = vi.fn().mockResolvedValue(status({ pending: 2, conflicted: 1 }));

const { result } = renderHook(() => useOfflineSync(undefined, getStatus));

await waitFor(() => expect(result.current.conflictState.pending).toBe(2));
expect(result.current.conflictState.conflicted).toBe(1);
});

it('keeps the count in step with the sync status', async () => {
const getStatus = vi.fn().mockResolvedValue(status({ deadLetter: 4 }));

const { result } = renderHook(() => useOfflineSync(undefined, getStatus));

await waitFor(() => expect(result.current.deadLetterCount).toBe(4));
expect(result.current.deadLetter.count).toBe(4);
});

it('exposes the breakdown by type', async () => {
const getDeadLetterSummary = vi.fn().mockResolvedValue({
count: 2,
byType: { course_progress: 2 },
oldestFailedAt: '2026-01-01T00:00:00.000Z',
});

const { result } = renderHook(() =>
useOfflineSync(undefined, undefined, { getDeadLetterSummary }),
);

await waitFor(() => expect(result.current.deadLetter.byType.course_progress).toBe(2));
});

it('refreshes on demand', async () => {
const getDeadLetterSummary = vi
.fn()
.mockResolvedValueOnce(summary(2))
.mockResolvedValueOnce(summary(0));

const { result } = renderHook(() =>
useOfflineSync(undefined, undefined, { getDeadLetterSummary }),
);

await waitFor(() => expect(result.current.deadLetterCount).toBe(2));

await act(async () => {
await result.current.refreshDeadLetter();
});

expect(result.current.deadLetterCount).toBe(0);
expect(result.current.hasDeadLetter).toBe(false);
});

it('retries the queue and refreshes afterwards', async () => {
const retryDeadLetter = vi.fn().mockResolvedValue(2);
const getDeadLetterSummary = vi
.fn()
.mockResolvedValueOnce(summary(2))
.mockResolvedValue(summary(0));

const { result } = renderHook(() =>
useOfflineSync(undefined, undefined, { getDeadLetterSummary, retryDeadLetter }),
);

await waitFor(() => expect(result.current.deadLetterCount).toBe(2));

let requeued = 0;
await act(async () => {
requeued = await result.current.retryDeadLetterOperations();
});

expect(requeued).toBe(2);
expect(retryDeadLetter).toHaveBeenCalledTimes(1);
expect(result.current.deadLetterCount).toBe(0);
});

it('reports nothing requeued when no retry action was supplied', async () => {
const { result } = renderHook(() => useOfflineSync());

let requeued = -1;
await act(async () => {
requeued = await result.current.retryDeadLetterOperations();
});

expect(requeued).toBe(0);
});

// A read that throws must not wedge the UI in a retrying state.
it('survives a failing dead-letter read', async () => {
const getDeadLetterSummary = vi.fn().mockRejectedValue(new Error('idb closed'));

const { result } = renderHook(() =>
useOfflineSync(undefined, undefined, { getDeadLetterSummary }),
);

await waitFor(() => expect(getDeadLetterSummary).toHaveBeenCalled());
expect(result.current.deadLetterCount).toBe(0);
});

it('clears the retrying flag after a failed retry', async () => {
const retryDeadLetter = vi.fn().mockRejectedValue(new Error('nope'));

const { result } = renderHook(() =>
useOfflineSync(undefined, undefined, { retryDeadLetter }),
);

await act(async () => {
await result.current.retryDeadLetterOperations();
});

expect(result.current.isRetryingDeadLetter).toBe(false);
});

it('does nothing on mount when no providers are supplied', () => {
const { result } = renderHook(() => useOfflineSync());

expect(result.current.deadLetterCount).toBe(0);
expect(result.current.conflictState).toEqual({ pending: 0, conflicted: 0, resolved: 0 });
});
});
78 changes: 77 additions & 1 deletion src/hooks/useOfflineSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,25 @@ export interface OfflineSyncStatusSnapshot extends ConflictState {
deadLetter: number;
}

/**
* Dead-letter detail the UI can prompt on.
*
* A count alone says something is stuck but not whether it is one operation
* from this morning or forty from last month — which is the difference
* between offering "retry" and telling the user to get help.
*/
export interface DeadLetterState {
count: number;
byType: Record<string, number>;
oldestFailedAt: string | null;
}

const EMPTY_DEAD_LETTER: DeadLetterState = {
count: 0,
byType: {},
oldestFailedAt: null,
};

/** Message posted by the service worker when a background sync fires. */
export const OFFLINE_SYNC_REQUESTED = 'OFFLINE_SYNC_REQUESTED';

Expand All @@ -29,11 +48,18 @@ export const OFFLINE_SYNC_REQUESTED = 'OFFLINE_SYNC_REQUESTED';
* @param getStatus Optional provider returning live conflict/dead-letter
* counts from the offline stores (e.g. useOfflineMode's
* getSyncStatus). When omitted the states stay at zero.
* @param options Optional dead-letter detail provider and retry action,
* so the UI can prompt for action on stuck operations.
*/
export function useOfflineSync(
syncCallback?: () => Promise<void>,
getStatus?: () => Promise<OfflineSyncStatusSnapshot>,
options: {
getDeadLetterSummary?: () => Promise<DeadLetterState>;
retryDeadLetter?: () => Promise<number>;
} = {},
) {
const { getDeadLetterSummary, retryDeadLetter } = options;
const [isOffline, setIsOffline] = useState<boolean>(false);
const [isSyncing, setIsSyncing] = useState<boolean>(false);
const [lastSynced, setLastSynced] = useState<Date | null>(null);
Expand All @@ -43,6 +69,8 @@ export function useOfflineSync(
resolved: 0,
});
const [deadLetterCount, setDeadLetterCount] = useState<number>(0);
const [deadLetter, setDeadLetter] = useState<DeadLetterState>(EMPTY_DEAD_LETTER);
const [isRetryingDeadLetter, setIsRetryingDeadLetter] = useState<boolean>(false);
const [conflicts, setConflicts] = useState<SyncConflict[]>([]);

// Initial offline check
Expand All @@ -52,6 +80,18 @@ export function useOfflineSync(
}
}, []);

/** Refreshes the dead-letter detail, when a provider was supplied. */
const refreshDeadLetter = useCallback(async () => {
if (!getDeadLetterSummary) return;
try {
const summary = await getDeadLetterSummary();
setDeadLetter(summary);
setDeadLetterCount(summary.count);
} catch (error) {
logger.error('Failed to refresh dead-letter queue', { error });
}
}, [getDeadLetterSummary]);

/** Refreshes the deterministic conflict state from the offline stores. */
const refreshStatus = useCallback(async () => {
if (!getStatus) return;
Expand All @@ -63,11 +103,39 @@ export function useOfflineSync(
resolved: status.resolved,
});
setDeadLetterCount(status.deadLetter);
setDeadLetter((current) =>
current.count === status.deadLetter ? current : { ...current, count: status.deadLetter },
);
} catch (error) {
logger.error('Failed to refresh offline sync status', { error });
}
}, [getStatus]);

// Dead-lettered operations are invisible until something reads them, and a
// user arriving with a stuck queue has not triggered a sync yet — so read
// once on mount rather than waiting for the next drain.
useEffect(() => {
void refreshStatus();
void refreshDeadLetter();
}, [refreshStatus, refreshDeadLetter]);

/** Re-enqueues every dead-lettered operation and syncs. */
const retryDeadLetterOperations = useCallback(async (): Promise<number> => {
if (!retryDeadLetter) return 0;

setIsRetryingDeadLetter(true);
try {
const requeued = await retryDeadLetter();
await refreshDeadLetter();
return requeued;
} catch (error) {
logger.error('Failed to retry dead-lettered operations', { error });
return 0;
} finally {
setIsRetryingDeadLetter(false);
}
}, [retryDeadLetter, refreshDeadLetter]);

const triggerSync = useCallback(async () => {
if (isOffline) return;

Expand All @@ -85,12 +153,13 @@ export function useOfflineSync(

setLastSynced(new Date());
await refreshStatus();
await refreshDeadLetter();
} catch (error) {
logger.error('Offline synchronization failed', { error });
} finally {
setIsSyncing(false);
}
}, [isOffline, syncCallback, refreshStatus]);
}, [isOffline, syncCallback, refreshStatus, refreshDeadLetter]);

useEffect(() => {
const handleOnline = () => {
Expand Down Expand Up @@ -126,6 +195,13 @@ export function useOfflineSync(
refreshStatus,
conflictState,
deadLetterCount,
/** Full dead-letter detail: count, breakdown by type, oldest failure. */
deadLetter,
/** True when operations are stuck and the UI should prompt for action. */
hasDeadLetter: deadLetterCount > 0,
refreshDeadLetter,
retryDeadLetterOperations,
isRetryingDeadLetter,
conflicts,
};
}
Loading
Loading