fix the unfixeble

This commit is contained in:
m
2026-09-11 10:49:12 +02:00
parent 76ed3aa66b
commit 2d9be3f489
13 changed files with 869 additions and 51 deletions
+66 -36
View File
@@ -1,4 +1,4 @@
import { listDirectory, listFoldersChunked, yieldToMainThread } from '../services/safDirectory';
import { listEntries, listFoldersChunked, yieldToMainThread } from '../services/safWalk';
import type { FileEntry } from '../services/safDirectory.types';
import {
checkpointDatabase,
@@ -37,55 +37,77 @@ function storedToEntry(stored: StoredFile): FileEntry {
}
function isChildOf(uri: string, rootUri: string): boolean {
return uri.startsWith(rootUri);
return uri === rootUri || uri.startsWith(rootUri + '/');
}
export async function syncRoot(rootResourceId: string): Promise<SyncResult> {
const root = await getFolder(rootResourceId);
if (!root) throw new Error('unknown root folder');
if (!root.uri) throw new Error(`root '${root.name}' has no physical uri`);
// Single-flight : toutes les marches SAF (`syncRoot` manuel et boucle de fond)
// sont sérialisées sur une chaîne module-level. Sans cela, deux `withTransaction`
// concurrents entrelacent leurs upserts et heurtent l'index partiel unique sur
// `uri` → `SQLITE_CONSTRAINT` → transaction annulée (FEEDBACK #8).
let chain: Promise<unknown> = Promise.resolve();
function enqueue<T>(work: () => Promise<T>): Promise<T> {
const p = chain.then(work, work);
chain = p.catch(() => {});
return p;
}
async function doSyncRoot(root: StoredFolder): Promise<SyncResult> {
const rootUri = root.uri;
if (!rootUri) throw new Error(`root '${root.name}' has no physical uri`);
// Phase 1 — listing SAF (I/O disque, HORS transaction) : la phase d'écriture
// ne doit verrouiller la base que pour les writes SQL purs, pas pendant tout
// le parcours (FEEDBACK #9 : l'UI lit la DB pendant un walk de plusieurs minutes).
const folders = (
await listFoldersChunked(rootUri, { 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,
listEntries(folder.uri).filter((entry) => !entry.isDirectory),
);
}
// Phase 2 — writes DB (transaction courte, SQL pur).
return withTransaction(async () => {
const seen = new Set<string>();
const folders = (
await listFoldersChunked(root.uri as string, { recursive: true, includeRoot: true })
).sort((a, b) => uriDepth(a.uri) - uriDepth(b.uri));
const resourceIdByUri = new Map<string, string>();
const savedFolders: StoredFolder[] = [];
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);
folder.uri === rootUri ? 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);
savedFolders.push(saved);
}
let lastYield = Date.now();
let files = 0;
for (const folder of savedFolders) {
for (const folder of folders) {
if (!folder.uri) continue;
if (Date.now() - lastYield >= 16) {
lastYield = Date.now();
await yieldToMainThread();
}
for (const entry of listDirectory(folder.uri)) {
if (entry.isDirectory) continue;
const entries = fileEntries.get(folder.uri) ?? [];
for (const entry of entries) {
seen.add(entry.uri);
await saveFile(entry, folder.resource_id);
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 as string) && folder.exists && !seen.has(folder.uri)) {
if (folder.uri && isChildOf(folder.uri, rootUri) && folder.exists && !seen.has(folder.uri)) {
await saveFolder(
{ uri: folder.uri, name: folder.name, exists: false, resource_id: folder.resource_id },
{ syncStatus: folder.syncStatus },
@@ -94,7 +116,7 @@ export async function syncRoot(rootResourceId: string): Promise<SyncResult> {
}
}
for (const file of await getFiles()) {
if (file.uri && isChildOf(file.uri, root.uri as string) && file.exists && !seen.has(file.uri)) {
if (file.uri && isChildOf(file.uri, rootUri) && file.exists && !seen.has(file.uri)) {
await saveFile(storedToEntry(file), file.folder_resource_id, {
resource_id: file.resource_id,
syncStatus: file.syncStatus,
@@ -103,20 +125,28 @@ export async function syncRoot(rootResourceId: string): Promise<SyncResult> {
}
}
return { rootUri: root.uri as string, folders: folders.length, files, missing };
return { rootUri, folders: folders.length, files, missing };
});
}
export async function syncRoot(rootResourceId: string): Promise<SyncResult> {
const root = await getFolder(rootResourceId);
if (!root) throw new Error('unknown root folder');
return enqueue(() => doSyncRoot(root));
}
export async function syncDevice(): Promise<SyncResult[]> {
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 syncRoot(root.resource_id));
}
await checkpointDatabase();
return results;
return enqueue(async () => {
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));
}
await checkpointDatabase();
return results;
});
}
export function useSyncDevice(intervalMs = 30_000): () => void {
+21 -7
View File
@@ -1,4 +1,4 @@
import { api, hasAuthToken } from '../api/client';
import { api, hasAuthToken, ApiError } from '../api/client';
import type { SyncOperation } from '../api/types';
import {
getActiveUserId,
@@ -18,6 +18,13 @@ const SYNC_BATCH_SIZE = 50;
// les dead-letters prématurées.
const TRANSIENT_RETRY_MS = 15_000;
function isTransientError(error: unknown): boolean {
if (!(error instanceof ApiError)) return true;
if (error.code === 'NETWORK_ERROR' || error.code.startsWith('HTTP_5')) return true;
if (error.code.startsWith('HTTP_4')) return false;
return true;
}
function toSyncOperation(op: PendingOperation): SyncOperation {
return {
operation_id: op.id,
@@ -43,12 +50,19 @@ export async function pushPendingOps(): Promise<PushResult> {
let result;
try {
result = (await api.syncOps(queued.map(toSyncOperation))).data;
} catch {
// Erreur transitoire : repousser sans toucher aux attempts
// (aucun dead-letter prématuré).
const retryAt = Date.now() + TRANSIENT_RETRY_MS;
await scheduleRetries(queued.map((op) => op.id), retryAt);
return { pushed: 0, retried: queued.length };
} catch (error) {
if (isTransientError(error)) {
const retryAt = Date.now() + TRANSIENT_RETRY_MS;
await scheduleRetries(queued.map((op) => op.id), retryAt);
return { pushed: 0, retried: queued.length };
}
const firstOp = queued[0];
await markPendingOperation(
firstOp.id,
'failed',
error instanceof ApiError ? `${error.code}: ${error.message}` : String(error),
);
return { pushed: 0, retried: 0 };
}
// `applied` = INDEX : les opérations [0, applied) sont confirmées côté serveur.