feat(mobile): boucle sync client↔serveur — outbox push + snapshot permissions
- api/types.ts : SyncOperation, SyncResult (sémantique `applied` = INDEX documentée),
ResourcePermission (miroir docs/api-v1.md §6, consommé tel quel par saveResourcePermission)
- api/client.ts : syncOps (POST /sync/ops), getSyncPermissions (GET /sync/permissions?after=),
hasAuthToken() ; INVALID_RESPONSE documenté dans docs/api-v1.md §1/§4
- services/db/repositories/pendingOps.ts : listQueuedOperations(limit) (batch FIFO due)
+ scheduleRetries(ids, retryAt) qui ne modifie JAMAIS attempts (erreur transitoire,
contrairement à markPendingOperation('failed') qui incrémente + backoff)
- features/syncOutbox.ts (nouveau) :
* pushPendingOps : batch ≤ SYNC_BATCH_SIZE (50) → ops [0, applied) passent 'completed' ;
op à l'index applied refusée → 'failed' (backoff, dead-letter MAX_PENDING_ATTEMPTS) ;
ops suivantes intactes (re-soumission) ; réseau/5xx → scheduleRetries (retried) sans
dead-letter prématurée ; applied==0 && failed → rien commité, aucun compteur touché
* refreshPermissions : delta monotone en mémoire lastPermissionCachedAt → `after`,
upsert saveResourcePermission ; no-op sans token
- features/syncDevice.ts : le tick useSyncDevice() enchaîne SAF walk → pushPendingOps()
→ refreshPermissions(), tous deux gardés par hasAuthToken()
- tests/syncOutbox.test.ts : 12 cas (FIFO/limit, succès complet/partiel, applied=0,
transitoire réseau + 5xx sans incrément, dead-letter après MAX_PENDING_ATTEMPTS,
delta snapshot croisé) — npm run test:sync
- tests/e2e.live.test.ts : smoke real backend (register → push → relecture /files/folders →
snapshot), skip si serveur down, SORTI de npm test via npm run test:e2e
- gates : npx tsc --noEmit + npm run test → 56/56 verts (24 db + 20 api + 12 sync)
This commit is contained in:
@@ -6,6 +6,9 @@ import type {
|
||||
FolderDto,
|
||||
ListFilesParams,
|
||||
OcrJob,
|
||||
ResourcePermission,
|
||||
SyncOperation,
|
||||
SyncResult,
|
||||
} from './types';
|
||||
|
||||
const API_BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL ?? 'http://localhost:8080/api/v1';
|
||||
@@ -18,6 +21,10 @@ export function setAuthToken(token: string | null): void {
|
||||
authToken = token;
|
||||
}
|
||||
|
||||
export function hasAuthToken(): boolean {
|
||||
return authToken !== null;
|
||||
}
|
||||
|
||||
type QueryParams = Record<string, string | number | boolean | undefined | null>;
|
||||
|
||||
function toQuery(params?: QueryParams): string {
|
||||
@@ -171,4 +178,14 @@ export const api = {
|
||||
|
||||
getOcrJob: (id: string) =>
|
||||
request<OcrJob>(`/ocr/jobs/${encodeURIComponent(id)}`),
|
||||
|
||||
syncOps: (operations: SyncOperation[]) =>
|
||||
request<SyncResult>('/sync/ops', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ operations }),
|
||||
}),
|
||||
|
||||
getSyncPermissions: (after?: number) =>
|
||||
request<ResourcePermission[]>(`/sync/permissions${after != null ? `?after=${after}` : ''}`),
|
||||
};
|
||||
@@ -55,4 +55,43 @@ export type ListParams = {
|
||||
|
||||
export type ListFilesParams = ListParams & {
|
||||
folderId?: string | null;
|
||||
};
|
||||
|
||||
// Miroir de docs/api-v1.md §6.1 — une entrée d'outbox côté serveur.
|
||||
// `operation_id` = `pending_operations.id` local (INTEGER).
|
||||
export type SyncOperation = {
|
||||
operation_id: number;
|
||||
ref_type?: 'resource' | 'share' | 'share_link' | null;
|
||||
ref_id?: number | null;
|
||||
resource_id: string;
|
||||
resource_type: 'folder' | 'file';
|
||||
operation: string;
|
||||
payload: Record<string, unknown>;
|
||||
};
|
||||
|
||||
// `applied` est un INDEX, pas un compte : nombre d'ops commitées depuis le
|
||||
// début du batch.
|
||||
// - succès → applied == operations.length, failed == null
|
||||
// - échec → applied == index de l'op refusée (< length),
|
||||
// failed = cette op ; reprise à operations.slice(applied)
|
||||
// - applied == 0 → rien de commité (1re op refusée, `failed` non-null, ou
|
||||
// batch vide) : tout est à rejouer, aucun compteur à
|
||||
// incrémenter côté client.
|
||||
// Invariant serveur : jamais applied == length avec failed non-null.
|
||||
export type SyncResult = {
|
||||
applied: number;
|
||||
failed: { operation_id: number; code: string; message: string } | null;
|
||||
};
|
||||
|
||||
// Miroir de docs/api-v1.md §6.2 — consommé tel quel par `saveResourcePermission`.
|
||||
export type ResourcePermission = {
|
||||
resource_id: string;
|
||||
resourceType: 'folder' | 'file';
|
||||
effectiveAccess: 'viewer' | 'commenter' | 'editor' | 'owner';
|
||||
inherit: boolean;
|
||||
ownerId: string | null;
|
||||
sharedById: string | null;
|
||||
expiresAt: number | null;
|
||||
cachedAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
Reference in New Issue
Block a user