remove doc + saf

This commit is contained in:
m
2026-09-11 11:14:20 +02:00
parent 2d9be3f489
commit 6666c5b469
5 changed files with 38 additions and 527 deletions
-200
View File
@@ -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<OcrJob>` (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 `<Stack>` 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 `<Tabs>` 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 `<Tabs>` 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/`
-277
View File
@@ -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<uri, FileEntry[]>
└─ 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: (<T>(work: () => Promise<T>) => Promise<T>) | null = null;
export function __setWithTransactionForTests(
impl: (<T>(work: () => Promise<T>) => Promise<T>) | 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<unknown> = Promise.resolve();
function enqueue<T>(work: () => Promise<T>): Promise<T> {
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<string, FileEntry[]>();
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<string>();
const resourceIdByUri = new Map<string, string>();
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<SyncResult> {
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<SyncResult[]> {
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<string, DirectoryEntry[]> = {
'/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
+16 -41
View File
@@ -4,13 +4,13 @@ import type {
FileEntry, FileEntry,
Folder, Folder,
FolderInfo, FolderInfo,
PickDirectoryOptions,
} from './safDirectory.types'; } from './safDirectory.types';
import { __setSafWalkImpl, yieldToMainThread } from './safWalk'; import { __setSafWalkImpl, yieldToMainThread } from './safWalk';
export { listFoldersChunked, yieldToMainThread, DEFAULT_WALK_BUDGET_MS } from './safWalk'; export { listFoldersChunked, yieldToMainThread, DEFAULT_WALK_BUDGET_MS } from './safWalk';
// Bootstrap: wire the real SAF implementation for the Expo runtime.
__setSafWalkImpl({ __setSafWalkImpl({
list: listDirectory, list: listDirectoryEntries,
info: getFolderInfo, info: getFolderInfo,
yield: yieldToMainThread, yield: yieldToMainThread,
}); });
@@ -20,43 +20,23 @@ export async function pickDirectory(initialUri?: string): Promise<Folder | null>
const directory = await Directory.pickDirectoryAsync(initialUri); const directory = await Directory.pickDirectoryAsync(initialUri);
if (!directory || !directory.uri) return null; if (!directory || !directory.uri) return null;
return toFolder({ uri: directory.uri, name: directory.name, exists: directory.exists }); return toFolder({ uri: directory.uri, name: directory.name, exists: directory.exists });
} catch { } catch (error) {
console.warn('[safDirectory] pickDirectory failed:', error);
return null; return null;
} }
} }
export function listDirectory(directoryUri: string): DirectoryEntry[] { export function listDirectoryEntries(directoryUri: string): DirectoryEntry[] {
const directory = new Directory(directoryUri); const directory = new Directory(directoryUri);
if (!directory.exists) return []; if (!directory.exists) return [];
try { try {
return directory.list().map(toEntry); return directory.list().map(toEntry);
} catch { } catch (error) {
console.warn('[safDirectory] listDirectoryEntries failed:', error);
return []; 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 { export function getFolderInfo(directoryUri: string): FolderInfo {
const directory = new Directory(directoryUri); const directory = new Directory(directoryUri);
return { 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 { export function createDirectory(directoryUri: string, name: string): Folder {
const directory = new Directory(Paths.join(directoryUri, name)); const directory = new Directory(Paths.join(directoryUri, name));
directory.create({ idempotent: true, intermediates: true }); directory.create({ idempotent: true, intermediates: true });
@@ -73,7 +54,10 @@ export function createDirectory(directoryUri: string, name: string): Folder {
} }
export function fileFromUri(uri: string): FileEntry { export function fileFromUri(uri: string): FileEntry {
const file = new File(uri); return toFileEntry(new File(uri));
}
function toFileEntry(file: File): FileEntry {
return { return {
uri: file.uri, uri: file.uri,
name: file.name, name: file.name,
@@ -87,21 +71,12 @@ export function fileFromUri(uri: string): FileEntry {
} }
function toEntry(item: Directory | File): DirectoryEntry { function toEntry(item: Directory | File): DirectoryEntry {
if (item instanceof Directory) { return item instanceof Directory
return toFolder({ uri: item.uri, name: item.name, exists: item.exists }); ? toFolder({ uri: item.uri, name: item.name, exists: item.exists })
} : toFileEntry(item);
return {
uri: item.uri,
name: item.name,
isDirectory: false,
extension: item.extension,
exists: item.exists,
size: item.size,
type: item.type,
lastModified: item.lastModified,
};
} }
// 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 { function toFolder({ uri, name, exists }: { uri: string; name?: string; exists?: boolean }): Folder {
const directory = exists === undefined ? new Directory(uri) : null; const directory = exists === undefined ? new Directory(uri) : null;
return { return {
+1
View File
@@ -2,6 +2,7 @@ export type Folder = {
uri: string; uri: string;
name: string; name: string;
isDirectory: true; isDirectory: true;
/** `undefined` when neither caller nor Directory fallback provided a value. */
exists?: boolean; exists?: boolean;
}; };
+19 -7
View File
@@ -52,19 +52,31 @@ export async function listFoldersChunked(
} }
let lastYield = Date.now(); let lastYield = Date.now();
const visit = async (uri: string) => {
if (Date.now() - lastYield >= budgetMs) { if (Date.now() - lastYield >= budgetMs) {
lastYield = Date.now(); lastYield = Date.now();
await impl.yield(); await impl.yield();
} }
for (const entry of impl.list(uri)) { // Stack of Folders identified by uri; push in reverse so the first-listed
if (entry.isDirectory) { // descendant is popped first, preserving the DFS pre-order of the recursive
result.push(entry); // traversal (a folder's subtree is fully explored before its later siblings).
await visit(entry.uri); 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();
}
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; return result;
} }