Files
Kazier/mobile/AGENTS.md
T
2026-09-11 10:49:12 +02:00

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 via openDatabaseAsync(name, { useNewConnection: true }); withDatabaseRetry() wraps any (db) → Promise<T> with one automatic recovery+retry; DatabaseRetryDeps seam allows unit-testing under plain Node.
  • 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); recovery tests: tests/dbClient.test.ts (isBrokenConnectionError + withDatabaseRetry via DatabaseRetryDeps, included in test:db).
  • 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. liveSession wraps each method with withDatabaseRetry to auto-recover on the expo-sqlite NPE.
  • 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).