Files
Kazier/mobile/AGENTS.md
T
m 197c7db8a9 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)
2026-09-10 20:23:55 +02:00

31 lines
6.0 KiB
Markdown

# Expo HAS CHANGED
Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before writing any code.
# Local storage
Persistence is SQLite-backed via `services/db/` (`expo-sqlite`, database `dot.db`, `user_version` = 4).
- `services/db/client.ts` — connection lifecycle: `getDatabase()`, `closeDatabase()`, `withTransaction()`.
- `services/db/migrations.ts` — versioned, chained migrations via `PRAGMA user_version` (list of `{ version, up }`), single-flight `WeakMap` lock, `migrateDatabase(db, targetVersion?)`. v4 is a **transactional rebuild** (folders/files drop `uri` keys, gain `resource_id` + partial unique index on non-null `uri`). Migration tests: `npm run test:migrations` (better-sqlite3 harness in `tests/migrations.test.ts`); repository tests: `npm run test:db` (both suites, better-sqlite3 via the `DbSession` seam).
- `services/db/session.ts``DbSession` (injectable `runAsync`/`getFirstAsync`/`getAllAsync`) ; repos get it through `getSession()`, tests override it with `__setDbForTests()`. `client.ts` loads `expo-sqlite` lazily so the test suites run under plain Node.
- `services/db/schema.ts``DATABASE_NAME`, `DATABASE_VERSION`, column-list constants, `DEVICE_USER_ID_KEY`, `PERMISSION_TTL_MS` (24h offline stale-cache).
- `services/db/id.ts``newResourceId()` opaque 32-hex `lower(hex(randomblob(16)))`, generated per row.
- `services/db/transitions.ts``transitionSyncStatus(from, event)`: per-row sync status transitions.
- `services/db/repositories/` — one module per table: `folders`, `files`, `user_preferences` (+ `getDeviceUserId`), `resource_permissions` (`permissions.ts` with `canAccess`/`canWrite`/`isOwner`, hierarchical via `WITH RECURSIVE`, `inherit`/`expires_at` honored, 24h stale-cache read-only downgrade), `shares`, `share_links`, `recipients`, `pending_operations` (`pendingOps.ts`, outbox: FIFO on `(created_at, id)`, failure schedules a `pending` retry with backoff, dead-letter `failed` after `MAX_PENDING_ATTEMPTS`).
- `services/localStorage.ts` is a thin re-export (`services/db`) kept for legacy imports.
- Tables: `folders`, `files`, `user_preferences`, `resource_permissions`, `shares`, `share_links`, `recipients`, `pending_operations`.
- 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`/`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>` / `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).