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

278 lines
9.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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