6.6 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()(single-flight),closeDatabase(),withTransaction(). Android-only (expo-sqlite#48999, unfixed in 57.x): on a dead NPE-like connection,recoverDatabase()drops the poisoned handle and reopens viaopenDatabaseAsync(name, { useNewConnection: true });withDatabaseRetry()wraps any(db) → Promise<T>with one automatic recovery+retry;DatabaseRetryDepsseam allows unit-testing under plain Node.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); recovery tests:tests/dbClient.test.ts(isBrokenConnectionError + withDatabaseRetry viaDatabaseRetryDeps, included intest:db).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.liveSessionwraps each method withwithDatabaseRetryto auto-recover on the expo-sqlite NPE.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).