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;
|
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?.();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user