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

6.0 KiB

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.tsDbSession (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.tsDATABASE_NAME, DATABASE_VERSION, column-list constants, DEVICE_USER_ID_KEY, PERMISSION_TTL_MS (24h offline stale-cache).
  • services/db/id.tsnewResourceId() opaque 32-hex lower(hex(randomblob(16))), generated per row.
  • services/db/transitions.tstransitionSyncStatus(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.tsuseSyncDevice()): after each SAF walk the same tick runs features/syncOutbox.tspushPendingOps() (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).