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:
2026-07-14 11:58:20 +02:00
co-authored by Claude Sonnet 4.6
parent a544818b5d
commit 5d1a4db42f
4 changed files with 55 additions and 26 deletions
+50 -1
View File
@@ -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; type TokenGetter = () => string | null;
let getToken: TokenGetter = () => null; let getToken: TokenGetter = () => null;
@@ -45,6 +60,8 @@ export interface RequestOptions {
/** Si false, no adjunta el token (p.ej. /login). Por defecto true. */ /** Si false, no adjunta el token (p.ej. /login). Por defecto true. */
auth?: boolean; auth?: boolean;
signal?: AbortSignal; signal?: AbortSignal;
/** Milisegundos hasta abortar por timeout. Por defecto 30s (120s si hay `form`). */
timeoutMs?: number;
} }
function buildUrl(path: string, query?: RequestOptions['query']): string { 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); 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) { if (res.status === 401 && auth) {
onUnauthorized?.(); onUnauthorized?.();
+5 -8
View File
@@ -12,9 +12,9 @@ import {
import * as api from '../api/endpoints'; import * as api from '../api/endpoints';
import { Project } from '../api/types'; import { Project } from '../api/types';
import { useSession } from '../auth/session'; 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 { isOnline } from '../net/connectivity';
import { initialPull, runSync } from '../sync/engine'; import { runSync } from '../sync/engine';
import { RootStackParamList } from '../navigation/types'; import { RootStackParamList } from '../navigation/types';
type Props = NativeStackScreenProps<RootStackParamList, 'Projects'>; type Props = NativeStackScreenProps<RootStackParamList, 'Projects'>;
@@ -57,12 +57,9 @@ export function ProjectsScreen({ navigation }: Props) {
setOpening(p.id); setOpening(p.id);
try { try {
await setActiveProjectId(p.id); await setActiveProjectId(p.id);
const online = await isOnline(); // runSync ya resuelve ambos casos: sin cursor baja el snapshot completo,
if (online) { // con cursor baja el delta (y vacía primero el outbox, vacío en la 1ª vez).
const cursor = await getCursor(p.id); if (await isOnline()) await runSync(p.id);
if (cursor) await runSync(p.id);
else await initialPull(p.id);
}
navigation.navigate('ProjectDetail', { projectId: p.id, name: p.name }); navigation.navigate('ProjectDetail', { projectId: p.id, name: p.name });
} finally { } finally {
setOpening(null); setOpening(null);
-6
View File
@@ -223,9 +223,3 @@ export async function runSync(projectId: number): Promise<SyncReport> {
return report; 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);
}
-11
View File
@@ -16,17 +16,6 @@ import {
IssueType, IssueType,
} from '../api/types'; } 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) ----- // ----- feature.update (editable, last-write-wins) -----
export async function updateFeature(input: { export async function updateFeature(input: {