- 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)
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 viaPRAGMA user_version(list of{ version, up }), single-flightWeakMaplock,migrateDatabase(db, targetVersion?). v4 is a transactional rebuild (folders/files dropurikeys, gainresource_id+ partial unique index on non-nulluri). Migration tests:npm run test:migrations(better-sqlite3 harness intests/migrations.test.ts); repository tests:npm run test:db(both suites, better-sqlite3 via theDbSessionseam).services/db/session.ts—DbSession(injectablerunAsync/getFirstAsync/getAllAsync) ; repos get it throughgetSession(), tests override it with__setDbForTests().client.tsloadsexpo-sqlitelazily 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-hexlower(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.tswithcanAccess/canWrite/isOwner, hierarchical viaWITH RECURSIVE,inherit/expires_athonored, 24h stale-cache read-only downgrade),shares,share_links,recipients,pending_operations(pendingOps.ts, outbox: FIFO on(created_at, id), failure schedules apendingretry with backoff, dead-letterfailedafterMAX_PENDING_ATTEMPTS).services/localStorage.tsis 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_idNOT NULL seeded fromdevice_user_id. - Folder and file per-row sync status:
local|cloud|local-cloud(placement state, transitions viatransitionSyncStatus). shares/share_linkshave NOsync_status: theirpushStatus(pending/synced/failed) is derived frompending_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 physicaluri; rows under the root with aurino longer seen are markedexists = 0(never deleted). Root folders are the rows withparent_resource_id IS NULL+ non-nulluri. - Sync loop (
features/syncDevice.ts→useSyncDevice()): after each SAF walk the same tick runsfeatures/syncOutbox.ts—pushPendingOps()(outbox →POST /sync/ops) thenrefreshPermissions()(delta snapshotGET /sync/permissions). Both are no-ops without an auth token (hasAuthToken(), set afterPOST /devices). - Outbox push semantics (
pushPendingOps): batch = firstSYNC_BATCH_SIZE(50) rows FIFO due (next_retry_at <= now) vialistQueuedOperations. Server replies a singleappliedindex (seeSyncResultcomment): ops[0, applied)→markPendingOperation('completed'); the op atappliedwhenfailedis non-null →markPendingOperation('failed')(backoff, dead-letter atMAX_PENDING_ATTEMPTS); ops after it staypendingand are re-sent next tick. Transient network/5xx errors →scheduleRetriesbumps onlynext_retry_at(neverattempts) — a failed batch is not dead-lettered prematurely;applied == 0 && failed != nullmeans nothing was committed (1st op refused), no counters touched beyond the single backoff. - Permissions snapshot (
refreshPermissions): in-memory monotonelastPermissionCachedAt(max servercachedAt) becomes theafterquery param of the next call; rows are upserted viasaveResourcePermission(24h TTL + read-only downgrade enforced bycanAccess). Unit tests:npm run test:sync; live smoke (needs backend + postgres):npm run test:e2e(skips when the server is down, NOT part ofnpm 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).