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;
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?.();
+5 -8
View File
@@ -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);
-6
View File
@@ -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);
}
-11
View File
@@ -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: {