refactor(sync): limpiar código muerto + timeout de red (post-review)
- Elimina recordProgressUpdate (quedó huérfano al quitar la pestaña Fases; el progreso se edita vía feature.update) y initialPull (redundante). - ProjectsScreen: al abrir un proyecto siempre runSync — su fase de pull ya resuelve snapshot (sin cursor) y delta (con cursor), y el outbox está vacío la primera vez. Menos ramas, misma semántica. - client.ts: AbortController con timeout (30s JSON / 120s multipart) y NetworkError que distingue timeout de error de red genérico. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+50
-1
@@ -20,6 +20,21 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** Error de red/tiempo (sin respuesta del servidor). `timedOut` indica timeout. */
|
||||
export class NetworkError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public timedOut = false,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'NetworkError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Timeout por defecto: peticiones JSON vs. subida de ficheros (multipart). */
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const UPLOAD_TIMEOUT_MS = 120_000;
|
||||
|
||||
type TokenGetter = () => string | null;
|
||||
|
||||
let getToken: TokenGetter = () => null;
|
||||
@@ -45,6 +60,8 @@ export interface RequestOptions {
|
||||
/** Si false, no adjunta el token (p.ej. /login). Por defecto true. */
|
||||
auth?: boolean;
|
||||
signal?: AbortSignal;
|
||||
/** Milisegundos hasta abortar por timeout. Por defecto 30s (120s si hay `form`). */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
function buildUrl(path: string, query?: RequestOptions['query']): string {
|
||||
@@ -85,7 +102,39 @@ export async function request<T>(path: string, opts: RequestOptions = {}): Promi
|
||||
body = JSON.stringify(json);
|
||||
}
|
||||
|
||||
const res = await fetch(buildUrl(path, query), { method, headers, body, signal });
|
||||
// Timeout con AbortController, combinado con la señal externa si la hay.
|
||||
const timeoutMs = opts.timeoutMs ?? (form ? UPLOAD_TIMEOUT_MS : DEFAULT_TIMEOUT_MS);
|
||||
const controller = new AbortController();
|
||||
const timedOut = { value: false };
|
||||
const timer = setTimeout(() => {
|
||||
timedOut.value = true;
|
||||
controller.abort();
|
||||
}, timeoutMs);
|
||||
if (signal) {
|
||||
if (signal.aborted) controller.abort();
|
||||
else signal.addEventListener('abort', () => controller.abort(), { once: true });
|
||||
}
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(buildUrl(path, query), {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (e) {
|
||||
if (timedOut.value) {
|
||||
throw new NetworkError(`Tiempo de espera agotado (${timeoutMs / 1000}s)`, true);
|
||||
}
|
||||
// Abort externo → propaga tal cual; resto → error de red genérico.
|
||||
if (signal?.aborted) throw e;
|
||||
throw new NetworkError(
|
||||
e instanceof Error ? `Error de red: ${e.message}` : 'Error de red',
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
if (res.status === 401 && auth) {
|
||||
onUnauthorized?.();
|
||||
|
||||
@@ -12,9 +12,9 @@ import {
|
||||
import * as api from '../api/endpoints';
|
||||
import { Project } from '../api/types';
|
||||
import { useSession } from '../auth/session';
|
||||
import { getCursor, getProjects, saveProjectList, setActiveProjectId } from '../db/repositories';
|
||||
import { getProjects, saveProjectList, setActiveProjectId } from '../db/repositories';
|
||||
import { isOnline } from '../net/connectivity';
|
||||
import { initialPull, runSync } from '../sync/engine';
|
||||
import { runSync } from '../sync/engine';
|
||||
import { RootStackParamList } from '../navigation/types';
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, 'Projects'>;
|
||||
@@ -57,12 +57,9 @@ export function ProjectsScreen({ navigation }: Props) {
|
||||
setOpening(p.id);
|
||||
try {
|
||||
await setActiveProjectId(p.id);
|
||||
const online = await isOnline();
|
||||
if (online) {
|
||||
const cursor = await getCursor(p.id);
|
||||
if (cursor) await runSync(p.id);
|
||||
else await initialPull(p.id);
|
||||
}
|
||||
// runSync ya resuelve ambos casos: sin cursor baja el snapshot completo,
|
||||
// con cursor baja el delta (y vacía primero el outbox, vacío en la 1ª vez).
|
||||
if (await isOnline()) await runSync(p.id);
|
||||
navigation.navigate('ProjectDetail', { projectId: p.id, name: p.name });
|
||||
} finally {
|
||||
setOpening(null);
|
||||
|
||||
@@ -223,9 +223,3 @@ export async function runSync(projectId: number): Promise<SyncReport> {
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
/** Primera sincronización: snapshot completo (sin cursor). */
|
||||
export async function initialPull(projectId: number): Promise<void> {
|
||||
const bundle = await getBundle(projectId);
|
||||
await applyBundle(bundle);
|
||||
}
|
||||
|
||||
@@ -16,17 +16,6 @@ import {
|
||||
IssueType,
|
||||
} from '../api/types';
|
||||
|
||||
// ----- progress (append-only en servidor) -----
|
||||
|
||||
export function recordProgressUpdate(input: {
|
||||
phase_id: number;
|
||||
progress: number;
|
||||
comment?: string;
|
||||
location?: { lat: number; lng: number };
|
||||
}): Promise<string> {
|
||||
return enqueueOperation('progress_update', 'create', { ...input });
|
||||
}
|
||||
|
||||
// ----- feature.update (editable, last-write-wins) -----
|
||||
|
||||
export async function updateFeature(input: {
|
||||
|
||||
Reference in New Issue
Block a user