From 6666c5b469da094b171375bbe061fe690ef0a9da Mon Sep 17 00:00:00 2001 From: m Date: Fri, 11 Sep 2026 11:14:20 +0200 Subject: [PATCH] remove doc + saf --- mobile/FEEDBACK.md | 200 ------------------- mobile/SAFFIX.md | 277 -------------------------- mobile/services/safDirectory.ts | 59 ++---- mobile/services/safDirectory.types.ts | 1 + mobile/services/safWalk.ts | 28 ++- 5 files changed, 38 insertions(+), 527 deletions(-) delete mode 100644 mobile/FEEDBACK.md delete mode 100644 mobile/SAFFIX.md diff --git a/mobile/FEEDBACK.md b/mobile/FEEDBACK.md deleted file mode 100644 index 4336bd8..0000000 --- a/mobile/FEEDBACK.md +++ /dev/null @@ -1,200 +0,0 @@ -# Revue du codebase mobile — Retours & Actions - -## Bugs confirmes (P0) - -### 1. `isChildOf` trop large -- **Fichier** : `features/syncDevice.ts:39-41` -- **Probleme** : `uri.startsWith(rootUri)` matche les siblins (`Documents` matche `Documents-archive`). Le pass de reconciliation marque `exists = 0` sur le mauvais root. -- **Fix** : `uri === rootUri || uri.startsWith(rootUri + '/')` - -### 2. Cache OCR casse -- **Fichier** : `hooks/useUpload.ts:35` -- **Probleme** : `setQueryData` stocke un `OcrJob` brut, mais `useOcrJob` attend `ApiData` (enveloppe `{ data }`). Resultat : `TypeError` sur `.data.status`. -- **Fix** : `queryClient.setQueryData(['ocr', result.data.id], result)` - -### 3. Outbox catch trop large -- **Fichier** : `features/syncOutbox.ts:46-52` -- **Probleme** : Toutes les erreurs (y compris 400 permanent) sont traitees comme transitoires. Un 400 boucle a 15s sans jamais dead-letter. -- **Fix** : Whitelist les erreurs transitoires (`NETWORK_ERROR`, `HTTP_5xx`, AbortError) ; les 4xx permanents doivent incrementer `attempts` via `markPendingOperation('failed')`. - -### 4. `canAccess` nie l'acces au proprietaire avec cache expire -- **Fichier** : `services/db/repositories/permissions.ts:151-171` -- **Probleme** : Si une permission exacte est en cache mais expiree, `canAccess` retourne `allowed: false` avant d'atteindre le fallback `owner_id === deviceUserId`. Le device peut etre exclu de ses propres fichiers. -- **Fix** : Verifier le ownership (deviceUserId === owner_id) dans la lignee **avant** le short-circuit du cache expire, ou au minimum avant de retourner `allowed: false`. - -### 5. `getFiles()` / `getFolderFolders()` ne filtrent pas `exists = 0` -- **Fichier** : `services/db/repositories/files.ts:75-79`, `folders.ts:96-98` -- **Probleme** : Les fichiers/dossiers supprimes du SAF restent affiches indefiniment dans l'UI. -- **Fix** : Ajouter `WHERE "exists" = 1` aux requetes, ou filtrer dans les ecrans. Attention : les rows cloud-only (`uri = NULL`) ont aussi `exists = false` — il faut distinguer "supprime du disque" de "jamais eu de copie locale". Ne pas filtrer dans le repository si le SAF walk a besoin de voir tous les rows ; filtrer dans les ecrans ou ajouter un flag `deleted_from_disk`. - -### 6. Tab navigation = `router.push` sur un Stack -- **Fichier** : `components/FloatingNavBar.tsx:62`, `app/_layout.tsx` -- **Probleme** : Le layout utilise un `` et la nav bar fait `router.push(tab.route)`. Chaque switch d'onglet empile un ecran. Le bouton systeme depile tout au lieu de quitter l'app. -- **Fix** : migrer vers le layout `` d'expo-router, ou utiliser `router.replace()` pour les onglets. - ---- - -## Bugs potentiels (P1) - -### 7. Race condition dans `recoverDatabase` -- **Fichier** : `services/db/client.ts:54-68` -- **Probleme** : `database = null` avant `closeAsync` permet l'ouverture d'une connexion concurrente via `getDatabase()`. -- **Fix** : Mettre `opening` a une promise de recovery **avant** de clear `database`, ou ajouter un verrou/flag `recovering`. - -### 8. Pas de single-flight sur les SAF walks -- **Fichier** : `features/syncDevice.ts` -- **Probleme** : Le loop background (`useSyncDevice`) et le bouton "Add folder" (`handlePickDirectory`) peuvent lancer `syncRoot` en parallele. Courses sur l'upsert URI unique -> `SQLITE_CONSTRAINT` -> transaction annulee. -- **Fix** : Module-level `inFlight` promise partagee par `syncRoot`/`syncDevice`. - -### 9. Walk SAF dans une transaction SQLite longue -- **Fichier** : `features/syncDevice.ts:48` -- **Probleme** : `withTransaction` ouvre avant le parcours SAF (potentiellement minutes). Toute lecture DB de l'UI est bloquee. -- **Fix** : Lister d'abord tous les folders/files SAF hors transaction, puis ouvrir la transaction uniquement pour les writes DB. - -### 10. `syncMode === 'manual'` se comporte comme `'full'` -- **Fichier** : `features/syncDevice.ts:127-128` -- **Probleme** : Seul `'none'` desactive le loop. Le mode `'manual'` fait aussi un walk complet toutes les 30s. -- **Fix** : Distinguer les modes ; `'manual'` ne devrait pousser que l'outbox/pas de walk SAF automatique. - -### 11. Course auth 401 vs bootstrap -- **Fichier** : `context/AuthContext.tsx:32-70` -- **Probleme** : Un 401 background peut appeler `signOut()` pendant que `bootstrap` restaure le token, restaurant une session revoquee. -- **Fix** : Monotonic "session epoch" verifiee apres chaque await. - -### 12. `markPendingOperation` race read-modify-write sur `attempts` -- **Fichier** : `services/db/repositories/pendingOps.ts:118-124` -- **Probleme** : Deux `SELECT attempts` concurrents lisent la meme valeur, ecrivent la meme. Le compteur ne progresse pas correctement. -- **Fix** : `UPDATE SET attempts = attempts + 1` atomique avec `RETURNING attempts`, ou `UPDATE ... WHERE id = ?` puis relire. - -### 13. `refreshPermissions` race cross-account -- **Fichier** : `features/syncOutbox.ts:78-89` -- **Probleme** : Lecture de `activeUserId`, puis requete reseau, puis `saveResourcePermission` relit `getActiveUserId()` (possiblement change). -- **Fix** : Capturer `activeUserId` + token au debut du tick, les reutiliser pour toute la duree. - -### 14. Migration v5 — `PRAGMA foreign_keys` hors `try/finally` -- **Fichier** : `services/db/migrations.ts:254, 259` -- **Probleme** : Si la transaction v4/v5 echoue, FK enforcement reste desactive jusqu'au restart du process. -- **Fix** : Wrapper `PRAGMA foreign_keys = OFF/ON` dans un `try/finally`. - -### 15. Dead-lettered operations bloquent `pushStatus` a `'failed'` permanemment -- **Fichier** : `services/db/repositories/pendingOps.ts` + `shares.ts`/`shareLinks.ts` (`PUSH_STATUS_SQL`) -- **Probleme** : Apres `MAX_PENDING_ATTEMPTS`, l'op est `'failed'` et `PUSH_STATUS_SQL` retourne toujours `'failed'` pour la ref, meme si un opration ulterieure reussit. -- **Fix** : Ajouter un path de cleanup/cancel pour les dead-letters, ou ignorer les `'failed'` plus anciens que le plus recent `'completed'` dans le subquery. - -### 16. Erreurs SAF silencieuses → faux "dossier vide" -- **Fichier** : `services/safDirectory.ts:23-25, 33-35` -- **Probleme** : `pickDirectory` et `listDirectory` catchent toutes les erreurs et retournent `null`/`[]`. Une revocation de permission SAF est interpretee comme "dossier vide" → tous les descendants marques `exists = 0` lors de la reconciliation. -- **Fix** : Distinguer "empty" (pas d'entrees) de "error" (exception lancee). Propager l'erreur ou retourner un type `{ entries: [], error?: string }`. - ---- - -## Mauvaises pratiques - -| # | Fichier | Probleme | Action | -|---|---|---|---| -| B1 | `context/AuthContext.tsx` | `signIn`/`signOut`/`continueWithoutAccount` non wrappes en `useCallback` | Ajouter `useCallback` | -| B2 | `components/FloatingNavBar.tsx:62` | `router.push(tab.route as any)` — `as any` masque les erreurs de type | Migrer vers layout `Tabs` expo-router ou typer correctement | -| B3 | `app/index.tsx:95-106` | Pas de loading/disable sur le bouton "Add folder" | Ajouter etat `syncing`, disable le bouton, spinner | -| B4 | `hooks/useUpload.ts:25-27` | `useOcrJob` poll infiniment sur `failed` | Stop sur `done` OU `failed` | -| B5 | `api/client.ts:110-126` | Timeout AbortController couvre `fetch()` mais pas `response.json()` | Passer le signal a `response.json()` ou ajouter un timeout sur la lecture body | -| B6 | 5+ fichiers | Couleurs/design tokens hardcodes | Extraire dans `constants/theme.ts` | -| B7 | `app/index.tsx` + `app/folder/[id].tsx` | `formatSize` duplique | Extraire dans `utils/format.ts` | -| B8 | `console.info`/`console.warn` | Disperses dans les chemins production | Gate derriere `__DEV__` ou logger | -| B9 | `app.json` | Nom = `"webui"` / `com.anonymous.webui` | Renommer en VaultDrop | -| B10 | `api/client.ts:18,21` | Erreurs hardcoded en francais (`'Serveur injoignable'`) | Utiliser i18n | -| B11 | `app/folder/[id].tsx:30-39` | 3 requetes DB sequentielles au focus | `Promise.all` | -| B12 | `app/login.tsx` | Password input manque `autoCapitalize="none"`, `autoCorrect={false}` | Ajouter les props | -| B13 | `app/folder/[id].tsx` | File rows Pressable mais onPress no-op | Retirer Pressable des fichiers ou ajouter une action | -| B14 | `app/_layout.tsx` + ecrans | `Stack.Screen options` defini en double (layout + ecran) | Un seul endroit | -| B15 | `features/syncDevice.ts` | Boucle sync tourne en background sans awareness `AppState` | Stopper le loop quand `AppState` est `background`, reprendre au `foreground` | -| B16 | `services/db/repositories/files.ts:20`, `folders.ts:15` | `getDeviceUserId()` appele a chaque `saveFolder`/`saveFile` (N+1, 4 round-trips par row) | Hoister `device_user_id` en parametre ou le cacher au debut du tick | -| B17 | `services/db/repositories/pendingOps.ts:100-107` | `scheduleRetries` fait N UPDATEs sequentiels | Batch `UPDATE WHERE id IN (...)` | - ---- - -## Pieces manquantes - -| # | Element | Action | -|---|---|---| -| M1 | `services/distantStorage.ts` — fichier vide | Supprimer | -| M2 | `app/search.tsx` et `app/settings.tsx` — placeholders vides | Implementer ou supprimer les onglets | -| M3 | `hooks/` — tous inutilises par les ecrans | Les brancher ou les supprimer | -| M4 | `getDeviceAuthToken`/`saveDeviceAuthToken` dans SQLite — non utilise (token dans SecureStore) | Supprimer ou marquer `@internal` | -| M5 | Aucun loading state dans les ecrans principaux | Ajouter des etats de chargement | -| M6 | Aucun error boundary React | Ajouter un `ErrorBoundary` au layout racine | -| M7 | Pas de pull-to-refresh | Ajouter `RefreshControl` | -| M8 | `AGENTS.md` mentionne v4 alors que le code est a v5 | Mettre a jour la doc | -| M9 | `localStorage.ts` — re-export legacy utilise par les ecrans | Migrer les imports vers `services/db` | - ---- - -## Accessibilite - -- **Zero proprietes d'accessibilite** dans l'app (0 matches `accessibilityRole`, `accessibilityLabel`, `aria-*`) -- Les tabs de la nav n'annoncent pas leur etat selectionne -- Les erreurs visuelles (couleur) ne sont pas announcees aux lecteurs d'ecran -- Les icons decoratives sont lues par les screen readers - -**Action** : Pass d'accessibilite minimal — `accessibilityRole`, `accessibilityState`, `accessibilityLabel` sur les elements interactifs. - ---- - -## Performance - -| Probleme | Fichier | Impact | Action | -|----------|---------|--------|--------| -| Walk SAF dans transaction SQLite longue | `features/syncDevice.ts:48` | Bloque les writes UI pendant le walk | Lister les entries SAF hors transaction, puis transaction uniquement pour les writes | -| N+1 pattern sur `saveFolder`/`saveFile` | `services/db/repositories/files.ts:20`, `folders.ts:15` | 4 round-trips par row (`getDeviceUserId` a chaque fois) | Hoister `device_user_id` en parametre ou le cacher au debut du tick | -| `scheduleRetries` — N UPDATEs sequentiels | `services/db/repositories/pendingOps.ts:100-107` | Lent pour gros batches | Un seul `UPDATE WHERE id IN (...)` | -| 3 requetes DB sequentielles au focus | `app/folder/[id].tsx:30-39` | Lent sur gros dossiers | `Promise.all` | -| Boucle sync sans awareness AppState | `features/syncDevice.ts:125-135` | Gaspille batterie en background | Stopper le loop quand `AppState` est `background` | -| Pas de pull-to-refresh | `app/index.tsx`, `app/folder/[id].tsx` | UI ne se met a jour qu'au refocus | Ajouter `RefreshControl` | - ---- - -## Actions planifiees (ordre de priorite) - -### P0 — Corriger immediatement -- [x] 1. Fix `isChildOf` — boundary `/` (`features/syncDevice.ts`) -- [x] 2. Fix cache OCR — `{ data: result.data }` (`hooks/useUpload.ts`) -- [x] 3. Distinguer erreurs reseau / erreurs API dans l'outbox (`features/syncOutbox.ts`) -- [x] 4. Fix `canAccess` owner vs cache expire (`services/db/repositories/permissions.ts`) -- [x] 5. Filtrer `exists` dans `getFiles`/`getFolderFolders` ou les ecrans -- [ ] 6. Migrer la tab navigation vers `` ou `router.replace()` (`components/FloatingNavBar.tsx`) - -### P1 — Fixer rapidement -- [ ] 7. Race `recoverDatabase` — serialiser l'ouverture (`services/db/client.ts`) -- [ ] 8. Single-flight sur `syncRoot`/`syncDevice` (`features/syncDevice.ts`) -- [ ] 9. `syncMode 'manual'` gate (`features/syncDevice.ts`) -- [ ] 10. `useCallback` pour `signIn`/`signOut` (`context/AuthContext.tsx`) -- [ ] 11. Atomic `attempts + 1` dans `markPendingOperation` (`services/db/repositories/pendingOps.ts`) -- [ ] 12. Course auth 401 vs bootstrap (`context/AuthContext.tsx`) -- [ ] 13. `refreshPermissions` race cross-account (`features/syncOutbox.ts`) -- [ ] 14. `PRAGMA foreign_keys` dans `try/finally` (`services/db/migrations.ts`) -- [ ] 15. Dead-letter cleanup pour `pushStatus` (`services/db/repositories/pendingOps.ts` + `shares.ts`/`shareLinks.ts`) -- [ ] 16. Erreurs SAF — distinguer "empty" de "error" (`services/safDirectory.ts`) -- [ ] 17. Awareness `AppState` sur la boucle sync (`features/syncDevice.ts`) -- [ ] 18. Hoister `device_user_id` (N+1) dans `saveFolder`/`saveFile` -- [ ] 19. Batch `scheduleRetries` (`UPDATE WHERE id IN (...)`) - -### P2 — Ameliorations -- [ ] 20. Error boundary au layout racine (`app/_layout.tsx`) -- [ ] 21. Loading/disable sur "Add folder" (`app/index.tsx`) -- [ ] 22. Extraire `formatSize` en utilitaire partage (`utils/format.ts`) -- [ ] 23. Centraliser les couleurs dans un theme (`constants/theme.ts`) -- [ ] 24. `useOcrJob` — stop poll sur `failed` (`hooks/useUpload.ts`) -- [ ] 25. Password input — `autoCapitalize`, `autoCorrect` (`app/login.tsx`) -- [ ] 26. Retirer Pressable des file rows ou ajouter une action (`app/folder/[id].tsx`) -- [ ] 27. `Promise.all` pour les 3 requetes DB au focus (`app/folder/[id].tsx`) -- [ ] 28. Pass d'accessibilite minimal -- [ ] 29. Gate `console.*` derriere `__DEV__` - -### P3 — Nettoyage -- [ ] 30. Supprimer `services/distantStorage.ts` -- [ ] 31. Supprimer `getDeviceAuthToken`/`saveDeviceAuthToken` de SQLite -- [ ] 32. Mettre a jour `AGENTS.md` a v5 -- [ ] 33. Migrer les imports `localStorage.ts` vers `services/db` -- [ ] 34. Renommer l'app (`app.json` + `package.json`) -- [ ] 35. Localiser les messages d'erreur API -- [ ] 36. Implementer `search.tsx` et `settings.tsx` ou supprimer les onglets -- [ ] 37. Supprimer/brancher les hooks inutilises dans `hooks/` diff --git a/mobile/SAFFIX.md b/mobile/SAFFIX.md deleted file mode 100644 index 82ec311..0000000 --- a/mobile/SAFFIX.md +++ /dev/null @@ -1,277 +0,0 @@ -# Fix #8 + #9 : Single-flight + listing SAF hors transaction - -## Diagnostic - -Bug #8 (pas de single-flight) + item perf « Walk SAF dans une transaction longue » -(FEEDBACK.md:44-52). La boucle `useSyncDevice` (30s, `syncMode:'full'` par défaut) -recouvre le `syncRoot` manuel ; sur gros dossier (>30s) les deux `withTransaction` -s'entrelacent → double INSERT sur l'index partiel unique `idx_folders_uri` / -`idx_files_uri` (migrations.ts:213-214) → `SQLITE_CONSTRAINT` → transaction -annulée → walk failed silencieux (index.tsx:103). - -Aucun test n'existe pour `syncRoot` / `syncDevice`. - -## Architecture cible - -``` -syncRoot(resourceId) - ├─ pre-flight : getFolder() (lecture rapide, hors garde) - └─ enqueue(() => doSyncRoot(root)) (sérialisé) - │ - ├─ Phase 1 : listing SAF (hors transaction) - │ listFoldersChunked() - │ for each folder → listDirectory() + yieldToMainThread() - │ → Map - │ - └─ Phase 2 : writes DB (withTransaction) - for each folder → saveFolder() - for each folder → saveFile() × n - réconciliation → exists=0 - → SyncResult -``` - -## Fichiers à modifier - -### 1. `services/db/client.ts` — `__setWithTransactionForTests` - -`syncRoot` utilise `withTransaction` (client.ts:110) qui passe par `getDatabase()` → -expo-sqlite. Les tests Node n'ont pas expo-sqlite. Il faut pouvoir substituer la -transaction pour les tests, comme `__setDbForTests` le fait pour `getSession()`. - -Ajouter une variable module-level et un setter : - -```typescript -let withTransactionOverride: ((work: () => Promise) => Promise) | null = null; - -export function __setWithTransactionForTests( - impl: ((work: () => Promise) => Promise) | null, -): void { - withTransactionOverride = impl; -} -``` - -Dans `withTransaction`, si override est défini, l'utiliser sinon chemin normal. - -Impact : aucun en production (`override` reste `null`). Test-only. - -### 2. `features/syncDevice.ts` — refonte en deux phases + single-flight - -#### 2a. Single-flight guard (module-level) - -```typescript -let chain: Promise = Promise.resolve(); - -function enqueue(work: () => Promise): Promise { - const p = chain.then(work, work); // s'exécute après le précédent, même en cas d'erreur - chain = p.catch(() => {}); // la chaîne absorbe les erreurs pour ne pas bloquer le suivant - return p; -} -``` - -Comportement : -- `syncRoot(A)` enqueued → démarre immédiatement (chain = resolved) -- `syncDevice()` enqueued pendant que A tourne → attend la fin de A -- Si A échoue, B démarre quand même (`.then(work, work)`) -- Les erreurs de B remontent au caller de B, pas à la chaîne - -#### 2b. Extraction `doSyncRoot` (privée) - -Extraire la logique actuelle de `syncRoot` dans une fonction privée -`doSyncRoot(root: StoredFolder)` qui prend un folder déjà résolu. - -#### 2c. Refonte de `doSyncRoot` en deux phases - -Phase 1 — Listing SAF (hors transaction, I/O disque) : - -```typescript -const folders = ( - await listFoldersChunked(root.uri!, { recursive: true, includeRoot: true }) -).sort((a, b) => uriDepth(a.uri) - uriDepth(b.uri)); - -const fileEntries = new Map(); -let lastYield = Date.now(); -for (const folder of folders) { - if (!folder.uri) continue; - if (Date.now() - lastYield >= 16) { - lastYield = Date.now(); - await yieldToMainThread(); - } - fileEntries.set( - folder.uri, - listDirectory(folder.uri).filter((e) => !e.isDirectory), - ); -} -``` - -Phase 2 — Writes DB (withTransaction, SQL pur) : - -```typescript -return withTransaction(async () => { - const seen = new Set(); - const resourceIdByUri = new Map(); - let files = 0; - - for (const folder of folders) { - seen.add(folder.uri); - const parentUri = dirname(folder.uri); - const parentResourceId = - folder.uri === root.uri ? null : (resourceIdByUri.get(parentUri) ?? root.resource_id); - const saved = await saveFolder( - { uri: folder.uri, name: folder.name, exists: folder.exists }, - { parentResourceId }, - ); - resourceIdByUri.set(folder.uri, saved.resource_id); - } - - for (const folder of folders) { - if (!folder.uri) continue; - const entries = fileEntries.get(folder.uri) ?? []; - for (const entry of entries) { - seen.add(entry.uri); - await saveFile(entry, resourceIdByUri.get(folder.uri)!); - files++; - } - } - - let missing = 0; - for (const folder of await getFolders()) { - if (folder.uri && isChildOf(folder.uri, root.uri!) && folder.exists && !seen.has(folder.uri)) { - await saveFolder( - { uri: folder.uri, name: folder.name, exists: false, resource_id: folder.resource_id }, - { syncStatus: folder.syncStatus }, - ); - missing++; - } - } - for (const file of await getFiles()) { - if (file.uri && isChildOf(file.uri, root.uri!) && file.exists && !seen.has(file.uri)) { - await saveFile(storedToEntry(file), file.folder_resource_id, { - resource_id: file.resource_id, - syncStatus: file.syncStatus, - }); - missing++; - } - } - - return { rootUri: root.uri!, folders: folders.length, files, missing }; -}); -``` - -#### 2d. Refonte des exports - -```typescript -export async function syncRoot(rootResourceId: string): Promise { - const root = await getFolder(rootResourceId); // pre-flight, hors garde - if (!root) throw new Error('unknown root folder'); - if (!root.uri) throw new Error(`root '${root.name}' has no physical uri`); - return enqueue(() => doSyncRoot(root)); // sérialisé -} - -export async function syncDevice(): Promise { - return enqueue(async () => { // un seul enqueue pour tout le batch - const roots = (await getFolders()).filter( - (folder) => folder.parent_resource_id === null && folder.uri !== null, - ); - const results: SyncResult[] = []; - for (const root of roots) { - results.push(await doSyncRoot(root)); // pas de double enqueue - } - await checkpointDatabase(); - return results; - }); -} -``` - -`useSyncDevice` : inchangée. Elle appelle `syncDevice()` qui passe par `enqueue`. - -### 3. `tests/syncDevice.test.ts` — nouveau fichier - -Harness : combinaison des patterns existants : -- `createHarness()` + `__setDbForTests(h)` (de repositories.test.ts) -- `__setWithTransactionForTests(async (work) => work())` (nouveau, simule sans BEGIN/COMMIT) -- `__setSafWalkForTests({ list, info })` (de safWalk.test.ts) - -Arbre SAF de test : - -```typescript -const tree: Record = { - '/root': [dir('/root/a'), dir('/root/b'), file('/root/f1.txt')], - '/root/a': [dir('/root/a/x'), file('/root/a/f2.txt')], - '/root/a/x': [], - '/root/b': [file('/root/b/f3.txt')], -}; -``` - -Tests : - -| # | Test | Vérifie | -|---|------|---------| -| 1 | Walk complet sur arbre moyen → comptes folders/files/missing corrects, `parent_resource_id` cohérent, `exists=1` | Fonctionnement de base | -| 2 | `syncRoot(A)` + `syncDevice()` lancés en parallèle via `Promise.all` → `maxConcurrent` (compteur dans mock `withTransaction`) = 1 | Single-flight fonctionne | -| 3 | Walk deux fois de suite → pas de doublon, pas de `SQLITE_CONSTRAINT`, mêmes `resource_id` | Idempotence | -| 4 | Walk une fois → modifier l'arbre SAF (supprimer un fichier) → walk à nouveau → `missing > 0`, le fichier a `exists=0` | Réconciliation | -| 5 | `syncDevice` avec 2 roots → les deux sont walkés, comptes corrects | Walk multi-roots | - -Setup/teardown : - -```typescript -beforeEach(() => { - h = createHarness(); - for (const migration of MIGRATIONS) await migration.up(h); - __setDbForTests(h); - __setWithTransactionForTests(async (work) => work()); - restoreFns.push(fakeSaf()); -}); - -afterEach(() => { - __setDbForTests(null); - __setWithTransactionForTests(null); - while (restoreFns.length) restoreFns.pop()?.(); -}); -``` - -### 4. `package.json` — ajouter le script de test - -```json -"test:syncDevice": "tsx --test tests/syncDevice.test.ts" -``` - -Et ajouter au script `test` : - -```json -"test": "npm run test:db && npm run test:api && npm run test:sync && npm run test:saf && npm run test:syncDevice" -``` - -## Ce qui ne change PAS - -| Fichier | Pourquoi | -|---------|----------| -| `services/db/repositories/folders.ts` | `saveFolder`/`saveFile` inchangés — le single-flight empêche les écritures concurrentes | -| `services/db/repositories/files.ts` | Idem | -| `app/index.tsx` | `handlePickDirectory` continue d'appeler `syncRoot()` — la sérialisation est transparente | -| `features/syncDevice.types.ts` | `SyncResult` identique | -| `services/safWalk.ts` | `listFoldersChunked` + `yieldToMainThread` inchangés | -| `services/db/migrations.ts` | Aucun changement de schéma | - -## Résultat attendu - -| Avant | Après | -|-------|-------| -| Walk de 3min → UI gelée (reads bloqués par la transaction) | Walk de 3min → UI fluide (listing hors transaction, writes rapides) | -| `syncRoot` manuel + tick background → `SQLITE_CONSTRAINT` silencieux | Sérialisation → tick attend la fin du manuel, pas de concurrence | - -## Vérification - -```bash -cd mobile && npx tsc --noEmit # typage -cd mobile && npm run test:db # régression repos + migrations -cd mobile && npm run test:saf # régression SAF walk -cd mobile && npm run test:syncDevice # nouveaux tests -cd mobile && npm test # tout -``` - -Test manuel device : -1. Ajouter un dossier vide → walk OK, files apparaissent -2. Ajouter un gros dossier (WhatsApp, >1000 fichiers) → scroll fluide pendant le walk, pas de `walk failed` dans les logs -3. Pendant le walk, attendre le tick de 30s → logs `syncDevice` et `syncRoot` jamais imbriqués (séquentiels) -4. Supprimer un fichier du dossier SAF → après prochain walk, `missing` > 0 dans les logs diff --git a/mobile/services/safDirectory.ts b/mobile/services/safDirectory.ts index 415c3ad..7228ce1 100644 --- a/mobile/services/safDirectory.ts +++ b/mobile/services/safDirectory.ts @@ -4,13 +4,13 @@ import type { FileEntry, Folder, FolderInfo, - PickDirectoryOptions, } from './safDirectory.types'; import { __setSafWalkImpl, yieldToMainThread } from './safWalk'; export { listFoldersChunked, yieldToMainThread, DEFAULT_WALK_BUDGET_MS } from './safWalk'; +// Bootstrap: wire the real SAF implementation for the Expo runtime. __setSafWalkImpl({ - list: listDirectory, + list: listDirectoryEntries, info: getFolderInfo, yield: yieldToMainThread, }); @@ -20,43 +20,23 @@ export async function pickDirectory(initialUri?: string): Promise const directory = await Directory.pickDirectoryAsync(initialUri); if (!directory || !directory.uri) return null; return toFolder({ uri: directory.uri, name: directory.name, exists: directory.exists }); - } catch { + } catch (error) { + console.warn('[safDirectory] pickDirectory failed:', error); return null; } } -export function listDirectory(directoryUri: string): DirectoryEntry[] { +export function listDirectoryEntries(directoryUri: string): DirectoryEntry[] { const directory = new Directory(directoryUri); if (!directory.exists) return []; try { return directory.list().map(toEntry); - } catch { + } catch (error) { + console.warn('[safDirectory] listDirectoryEntries failed:', error); return []; } } -export function listFolders(directoryUri: string, options: PickDirectoryOptions = {}): Folder[] { - const { recursive = false, includeRoot = false } = options; - if (!recursive) { - return listDirectory(directoryUri).filter((entry): entry is Folder => entry.isDirectory); - } - - const result: Folder[] = []; - if (includeRoot) { - result.push({ ...getFolderInfo(directoryUri), isDirectory: true }); - } - const visit = (uri: string) => { - for (const entry of listDirectory(uri)) { - if (entry.isDirectory) { - result.push(entry); - visit(entry.uri); - } - } - }; - visit(directoryUri); - return result; -} - export function getFolderInfo(directoryUri: string): FolderInfo { const directory = new Directory(directoryUri); return { @@ -66,6 +46,7 @@ export function getFolderInfo(directoryUri: string): FolderInfo { }; } +/** Creates a directory. Intentionally lets exceptions propagate (permissions, quota, etc). */ export function createDirectory(directoryUri: string, name: string): Folder { const directory = new Directory(Paths.join(directoryUri, name)); directory.create({ idempotent: true, intermediates: true }); @@ -73,7 +54,10 @@ export function createDirectory(directoryUri: string, name: string): Folder { } export function fileFromUri(uri: string): FileEntry { - const file = new File(uri); + return toFileEntry(new File(uri)); +} + +function toFileEntry(file: File): FileEntry { return { uri: file.uri, name: file.name, @@ -87,21 +71,12 @@ export function fileFromUri(uri: string): FileEntry { } function toEntry(item: Directory | File): DirectoryEntry { - if (item instanceof Directory) { - return toFolder({ uri: item.uri, name: item.name, exists: item.exists }); - } - return { - uri: item.uri, - name: item.name, - isDirectory: false, - extension: item.extension, - exists: item.exists, - size: item.size, - type: item.type, - lastModified: item.lastModified, - }; + return item instanceof Directory + ? toFolder({ uri: item.uri, name: item.name, exists: item.exists }) + : toFileEntry(item); } +// Perf: avoids constructing a Directory when all fields are already provided by the caller. function toFolder({ uri, name, exists }: { uri: string; name?: string; exists?: boolean }): Folder { const directory = exists === undefined ? new Directory(uri) : null; return { @@ -110,4 +85,4 @@ function toFolder({ uri, name, exists }: { uri: string; name?: string; exists?: isDirectory: true, exists: exists ?? directory?.exists, }; -} \ No newline at end of file +} diff --git a/mobile/services/safDirectory.types.ts b/mobile/services/safDirectory.types.ts index 00a80c5..f7d718d 100644 --- a/mobile/services/safDirectory.types.ts +++ b/mobile/services/safDirectory.types.ts @@ -2,6 +2,7 @@ export type Folder = { uri: string; name: string; isDirectory: true; + /** `undefined` when neither caller nor Directory fallback provided a value. */ exists?: boolean; }; diff --git a/mobile/services/safWalk.ts b/mobile/services/safWalk.ts index 7873bc4..3275116 100644 --- a/mobile/services/safWalk.ts +++ b/mobile/services/safWalk.ts @@ -52,19 +52,31 @@ export async function listFoldersChunked( } let lastYield = Date.now(); - const visit = async (uri: string) => { + if (Date.now() - lastYield >= budgetMs) { + lastYield = Date.now(); + await impl.yield(); + } + // Stack of Folders identified by uri; push in reverse so the first-listed + // descendant is popped first, preserving the DFS pre-order of the recursive + // traversal (a folder's subtree is fully explored before its later siblings). + const stack: Folder[] = impl + .list(directoryUri) + .filter((entry): entry is Folder => entry.isDirectory) + .reverse(); + while (stack.length > 0) { if (Date.now() - lastYield >= budgetMs) { lastYield = Date.now(); await impl.yield(); } - for (const entry of impl.list(uri)) { - if (entry.isDirectory) { - result.push(entry); - await visit(entry.uri); - } + const folder = stack.pop()!; + result.push(folder); + const children = impl + .list(folder.uri) + .filter((entry): entry is Folder => entry.isDirectory); + for (let i = children.length - 1; i >= 0; i--) { + stack.push(children[i]); } - }; - await visit(directoryUri); + } return result; }