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:
m
2026-09-10 20:23:55 +02:00
parent ea848fa9c7
commit 197c7db8a9
12 changed files with 657 additions and 5 deletions
+5 -2
View File
@@ -18,10 +18,13 @@ Persistence is SQLite-backed via `services/db/` (`expo-sqlite`, database `dot.db
- Canonical identity: `resource_id` (opaque, unique) on folders/files/shares/share_links; `uri` (physical SAF path) is **nullable**, NULL = cloud-only; `owner_id` NOT NULL seeded from `device_user_id`.
- Folder and file per-row sync status: `local` | `cloud` | `local-cloud` (placement state, transitions via `transitionSyncStatus`).
- `shares`/`share_links` have NO `sync_status`: their `pushStatus` (`pending`/`synced`/`failed`) is **derived** from `pending_operations` (`ref_type` = `share`|`share_link`, `ref_id`).
- Query usage: `getFiles(folderResourceId?)`, `getFolders()`, `getFolderFolders(parentResourceId)`, `getFolder`/`getFile(resourceId)`, `saveFolder`/`saveDirectory`, `saveFile(file, folderResourceId)`, `removeFolder`/`removeFile(resourceId)`, `saveUserPreferences`/`getUserPreferences`/`getDeviceUserId`, `saveResourcePermission`/`getResourcePermission`, `canAccess(resourceId, type, level)`, `saveShare`/`getShares`/`removeShare`, `createShareLink`/`getShareLinks`/`incrementLinkDownloads`/`revokeShareLink`, `saveRecipient`/`getRecipients`, `enqueuePendingOperation`/`getNextQueuedOperation`/`markPendingOperation`.
- Query usage: `getFiles(folderResourceId?)`, `getFolders()`, `getFolderFolders(parentResourceId)`, `getFolder`/`getFile(resourceId)`, `saveFolder`/`saveDirectory`, `saveFile(file, folderResourceId)`, `removeFolder`/`removeFile(resourceId)`, `saveUserPreferences`/`getUserPreferences`/`getDeviceUserId`, `saveResourcePermission`/`getResourcePermission`, `canAccess(resourceId, type, level)`, `saveShare`/`getShares`/`removeShare`, `createShareLink`/`getShareLinks`/`incrementLinkDownloads`/`revokeShareLink`, `saveRecipient`/`getRecipients`, `enqueuePendingOperation`/`getNextQueuedOperation`/`listQueuedOperations`/`scheduleRetries`/`markPendingOperation`/`MAX_PENDING_ATTEMPTS`.
- SAF walk (`features/syncDevice.ts`, `syncDevice()`/`syncRoot()`): **two passes** (all folders sorted by uri depth, then all files) inside a **single transaction** (`withTransaction`), receives sync: an interruption rolls back entirely. Reconciles by physical `uri`; rows under the root with a `uri` no longer seen are **marked `exists = 0`** (never deleted). Root folders are the rows with `parent_resource_id IS NULL` + non-null `uri`.
- Sync loop (`features/syncDevice.ts``useSyncDevice()`): after each SAF walk the same tick runs `features/syncOutbox.ts``pushPendingOps()` (outbox → `POST /sync/ops`) then `refreshPermissions()` (delta snapshot `GET /sync/permissions`). Both are **no-ops without an auth token** (`hasAuthToken()`, set after `POST /devices`).
- Outbox push semantics (`pushPendingOps`): batch = first `SYNC_BATCH_SIZE` (50) rows FIFO due (`next_retry_at <= now`) via `listQueuedOperations`. Server replies a **single `applied` index** (see `SyncResult` comment): ops `[0, applied)``markPendingOperation('completed')`; the op at `applied` when `failed` is non-null → `markPendingOperation('failed')` (backoff, dead-letter at `MAX_PENDING_ATTEMPTS`); ops after it stay `pending` and are re-sent next tick. **Transient** network/5xx errors → `scheduleRetries` bumps only `next_retry_at` (**never `attempts`**) — a failed batch is not dead-lettered prematurely; `applied == 0 && failed != null` means nothing was committed (1st op refused), no counters touched beyond the single backoff.
- Permissions snapshot (`refreshPermissions`): in-memory monotone `lastPermissionCachedAt` (max server `cachedAt`) becomes the `after` query param of the next call; rows are upserted via `saveResourcePermission` (24h TTL + read-only downgrade enforced by `canAccess`). Unit tests: `npm run test:sync`; live smoke (needs backend + postgres): `npm run test:e2e` (skips when the server is down, NOT part of `npm test`).
- Heavy processing stays server-side; SQLite only persists local metadata/state.
# REST API client
`api/client.ts` + `api/types.ts` = the client-side **API contract** (server must implement it; backend Go is the source of truth once built). Base URL = `EXPO_PUBLIC_API_BASE_URL` (défaut `http://localhost:8080/api/v1`). Envelope: success `{ data, meta?: { page, pageSize, total } }`, errors normalized to `ApiError` (`code` from `{ error: { code, message } }`, or `NETWORK_ERROR` / `HTTP_<status>`). Multipart upload needs the platform FormData (uri/name/type) — never set `Content-Type` manually. TanStack Query v5 providers live in `app/_layout.tsx`; hooks in `hooks/` (`useFiles`, `useSearch`, `useUpload`, OCR jobs).
`api/client.ts` + `api/types.ts` = the client-side **API contract** (server must implement it; backend Go is the source of truth once built). Base URL = `EXPO_PUBLIC_API_BASE_URL` (défaut `http://localhost:8080/api/v1`). Envelope: success `{ data, meta?: { page, pageSize, total } }`, errors normalized to `ApiError` (`code` from `{ error: { code, message } }`, or `NETWORK_ERROR` / `HTTP_<status>` / `INVALID_RESPONSE` for 2xx bodies without a valid envelope). Multipart upload needs the platform FormData (uri/name/type) — never set `Content-Type` manually. TanStack Query v5 providers live in `app/_layout.tsx`; hooks in `hooks/` (`useFiles`, `useSearch`, `useUpload`, OCR jobs).