remove react native app

This commit is contained in:
m
2026-09-11 23:44:04 +02:00
parent 4314770096
commit 13deb5e893
65 changed files with 0 additions and 14432 deletions
-45
View File
@@ -1,45 +0,0 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
.env
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android
modules/*/android/build/
modules/*/android/.gradle/
-30
View File
@@ -1,30 +0,0 @@
# 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.ts``DbSession` (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.ts``DATABASE_NAME`, `DATABASE_VERSION`, column-list constants, `DEVICE_USER_ID_KEY`, `PERMISSION_TTL_MS` (24h offline stale-cache).
- `services/db/id.ts``newResourceId()` opaque 32-hex `lower(hex(randomblob(16)))`, generated per row.
- `services/db/transitions.ts``transitionSyncStatus(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.ts``useSyncDevice()`): after each SAF walk the same tick runs `features/syncOutbox.ts``pushPendingOps()` (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).
-21
View File
@@ -1,21 +0,0 @@
The MIT License (MIT)
Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-226
View File
@@ -1,226 +0,0 @@
import type {
ApiData,
ApiErrorBody,
DeviceRegistration,
FileDto,
FolderDto,
ListFilesParams,
LoginResponse,
OcrJob,
ResolvedUser,
ResourcePermission,
SyncOperation,
SyncResult,
} from './types';
const API_BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL ?? 'http://localhost:8080/api/v1';
export const DEFAULT_TIMEOUT_MS = 15_000;
let authToken: string | null = null;
export function setAuthToken(token: string | null): void {
authToken = token;
}
export function hasAuthToken(): boolean {
return authToken !== null;
}
// Notification de 401 reçus sur un endpoint protégé (hors login lui-même) —
// AuthContext s'en sert pour purger la session (token révoqué/expiré).
let onUnauthorized: (() => void) | null = null;
export function setUnauthorizedHandler(handler: (() => void) | null): void {
onUnauthorized = handler;
}
type QueryParams = Record<string, string | number | boolean | undefined | null>;
function toQuery(params?: QueryParams): string {
if ( !params ) return '';
const search = new URLSearchParams();
for ( const [key, value] of Object.entries(params) ) {
if ( value === undefined || value === null ) continue;
search.set(key, String(value));
}
const query = search.toString();
return query ? `?${query}` : '';
}
function mergeHeaders(init?: HeadersInit): HeadersInit | undefined {
if ( !authToken ) return init;
const merged = new Headers(init);
if ( !merged.has('Authorization') ) {
merged.set('Authorization', `Bearer ${authToken}`);
}
return merged;
}
export class ApiError extends Error {
readonly code: string;
constructor(code: string, message: string) {
super(message);
this.name = 'ApiError';
this.code = code;
}
}
async function request<T>(
path: string,
init: RequestInit = {},
timeoutMs: number = DEFAULT_TIMEOUT_MS,
opts: { skipUnauthorizedHandling?: boolean } = {},
): Promise<ApiData<T>> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
let response: Response;
try {
response = await fetch(`${API_BASE_URL}${path}`, {
...init,
headers: mergeHeaders(init.headers),
signal: controller.signal,
});
} catch {
throw new ApiError('NETWORK_ERROR', 'Serveur injoignable');
}
if (response.status === 401 && !opts.skipUnauthorizedHandling) {
onUnauthorized?.();
}
const body = (await response.json().catch(() => null)) as
| ApiData<T>
| ApiErrorBody
| unknown[]
| string
| number
| null;
if ( !response.ok ) {
const error = body != null && isErrorBody(body) ? body.error : null;
throw new ApiError(
error?.code ?? `HTTP_${response.status}`,
error?.message ?? response.statusText
);
}
// 2xx : enveloppe `{ data }` obligatoire. Un corps illisible (HTML d'un
// proxy, JSON malformé, corps vide) ou un objet sans clé `data` est une
// réponse invalide → ApiError propre, jamais de TypeError ni de data
// indéfini silencieux. Les listes brutes (`[]`) restent tolérées.
if ( body === null || isErrorBody(body) ) {
throw new ApiError('INVALID_RESPONSE', 'Réponse serveur invalide (enveloppe {data} attendue)');
}
if ( !isRecord(body) ) {
return { data: body as T };
}
if ( !('data' in body) ) {
throw new ApiError('INVALID_RESPONSE', 'Réponse serveur invalide (enveloppe {data} attendue)');
}
return body as ApiData<T>;
} finally {
clearTimeout(timer);
}
}
function isErrorBody(body: unknown): body is ApiErrorBody {
return (
typeof body === 'object' &&
body !== null &&
'error' in body &&
(body as { error?: { code?: unknown } }).error != null
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export const api = {
getApiBaseUrl: () => API_BASE_URL,
health: () => request<{ status: string }>('/health'),
registerDevice: (deviceId: string) =>
request<DeviceRegistration>('/devices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceId }),
}),
// Seule porte d'émission de token (V1). Le 401 = mauvaises identifiants
// (normal sur l'écran de login) : on ne déclenche pas la purge de session.
login: (username: string, password: string, deviceId: string) =>
request<LoginResponse>('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, device_id: deviceId }),
}, DEFAULT_TIMEOUT_MS, { skipUnauthorizedHandling: true }),
changePassword: (currentPassword: string, newPassword: string) =>
request<{ id: string }>('/users/me/password', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
}),
// Résolution exacte d'un destinataire par username — jamais de listing.
resolveUser: (username: string) =>
request<ResolvedUser>(`/users/resolve${toQuery({ username })}`),
listFiles: (params?: ListFilesParams) =>
request<FileDto[]>(`/files${toQuery(params)}`),
getFile: (id: string) =>
request<FileDto>(`/files/${encodeURIComponent(id)}`),
deleteFile: (id: string) =>
request<{ id: string }>(`/files/${encodeURIComponent(id)}`, {
method: 'DELETE',
}),
searchFiles: (q: string, params?: QueryParams) =>
request<FileDto[]>(`/files/search${toQuery({ q, ...params })}`),
listFolders: () => request<FolderDto[]>('/files/folders'),
uploadFile: (
file: { uri: string; name: string; mimeType: string },
folderId?: string | null
) => {
const form = new FormData();
form.append('file', {
uri: file.uri,
name: file.name,
type: file.mimeType,
} as unknown as Blob);
if ( folderId ) form.append('folderId', folderId);
return request<FileDto>('/files/upload', {
method: 'POST',
body: form,
});
},
createOcrJob: (fileId: string) =>
request<OcrJob>('/ocr/jobs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileId }),
}),
getOcrJob: (id: string) =>
request<OcrJob>(`/ocr/jobs/${encodeURIComponent(id)}`),
syncOps: (operations: SyncOperation[]) =>
request<SyncResult>('/sync/ops', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ operations }),
}),
getSyncPermissions: (after?: number) =>
request<ResourcePermission[]>(`/sync/permissions${after != null ? `?after=${after}` : ''}`),
};
-119
View File
@@ -1,119 +0,0 @@
export type ApiMeta = {
page: number;
pageSize: number;
total: number;
};
export type ApiData<T> = {
data: T;
meta?: ApiMeta;
};
export type ApiErrorBody = {
error: {
code: string;
message: string;
};
};
export type FileDto = {
id: string;
name: string;
size: number;
mimeType?: string | null;
folderId?: string | null;
tags?: string[];
createdAt?: string;
updatedAt?: string;
};
export type FolderDto = {
id: string;
name: string;
parentId?: string | null;
};
export type OcrJobStatus = 'queued' | 'processing' | 'done' | 'failed';
export type OcrJob = {
id: string;
status: OcrJobStatus;
text?: string | null;
error?: string | null;
};
export type DeviceRegistration = {
deviceId: string;
};
export type User = {
id: string;
username: string;
is_admin: boolean;
};
export type LoginRequest = {
username: string;
password: string;
device_id: string;
};
export type LoginResponse = {
token: string;
expires_at: number;
user: User;
};
export type ResolvedUser = {
id: string;
username: string;
};
export type ListParams = {
page?: number;
pageSize?: number;
sort?: string;
};
export type ListFilesParams = ListParams & {
folderId?: string | null;
};
// Miroir de docs/api-v1.md §6.1 — une entrée d'outbox côté serveur.
// `operation_id` = `pending_operations.id` local (INTEGER).
export type SyncOperation = {
operation_id: number;
ref_type?: 'resource' | 'share' | 'share_link' | null;
ref_id?: number | null;
resource_id: string;
resource_type: 'folder' | 'file';
operation: string;
payload: Record<string, unknown>;
};
// `applied` est un INDEX, pas un compte : nombre d'ops commitées depuis le
// début du batch.
// - succès → applied == operations.length, failed == null
// - échec → applied == index de l'op refusée (< length),
// failed = cette op ; reprise à operations.slice(applied)
// - applied == 0 → rien de commité (1re op refusée, `failed` non-null, ou
// batch vide) : tout est à rejouer, aucun compteur à
// incrémenter côté client.
// Invariant serveur : jamais applied == length avec failed non-null.
export type SyncResult = {
applied: number;
failed: { operation_id: number; code: string; message: string } | null;
};
// Miroir de docs/api-v1.md §6.2 — consommé tel quel par `saveResourcePermission`.
export type ResourcePermission = {
resource_id: string;
resourceType: 'folder' | 'file';
effectiveAccess: 'viewer' | 'commenter' | 'editor' | 'owner';
inherit: boolean;
ownerId: string | null;
sharedById: string | null;
expiresAt: number | null;
cachedAt: number;
updatedAt: number;
};
-33
View File
@@ -1,33 +0,0 @@
{
"expo": {
"name": "webui",
"slug": "webui",
"scheme": "webui",
"version": "2.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.anonymous.webui"
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/android-icon-foreground.png",
"backgroundImage": "./assets/android-icon-background.png",
"monochromeImage": "./assets/android-icon-monochrome.png"
},
"package": "com.anonymous.webui"
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-router",
"expo-sqlite",
"expo-localization",
"expo-secure-store"
]
}
}
-50
View File
@@ -1,50 +0,0 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Redirect, Stack } from 'expo-router';
import { ActivityIndicator, View } from 'react-native';
import { AuthProvider, useAuth } from '../context/AuthContext';
import i18n from '../i18n';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 2,
},
},
});
function AuthGate() {
const { status } = useAuth();
if (status === 'loading') {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<ActivityIndicator size="large" />
</View>
);
}
return (
<>
<Stack>
<Stack.Screen name="index" options={{ title: i18n.t('files') }} />
<Stack.Screen name="search" options={{ title: i18n.t('search') }} />
<Stack.Screen name="settings" options={{ title: i18n.t('configuration') }} />
<Stack.Screen name="folder/[id]" options={{ title: i18n.t('folder') }} />
<Stack.Screen name="login" options={{ title: i18n.t('login_title') }} />
</Stack>
{/* Rediriger vers la connexion uniquement si l'utilisateur n'a pas de session ET n'est pas en mode local. */}
{status === 'signedOut' ? <Redirect href="/login" /> : null}
</>
);
}
export default function RootLayout() {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<AuthGate />
</AuthProvider>
</QueryClientProvider>
);
}
-120
View File
@@ -1,120 +0,0 @@
import { Stack, useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router';
import { useCallback, useState } from 'react';
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
import { getFolder, getFolderFolders, getFiles } from '../../services/localStorage';
import type { StoredFile, StoredFolder } from '../../services/db/types';
import i18n from '../../i18n';
type Row = {
key: string;
kind: 'folder' | 'file';
label: string;
meta: string;
resourceId: string;
};
function formatSize(bytes: number): string {
if ( bytes < 1024 ) return `${bytes} ${i18n.t('bytes')}`;
if ( bytes < 1024 * 1024 ) return `${(bytes / 1024).toFixed(1)} ${i18n.t('kilobytes')}`;
return `${(bytes / (1024 * 1024)).toFixed(1)} ${i18n.t('megabytes')}`;
}
export default function FolderScreen() {
const router = useRouter();
const { id } = useLocalSearchParams<{ id: string }>();
const [folder, setFolder] = useState<StoredFolder | null>(null);
const [subfolders, setSubfolders] = useState<StoredFolder[]>([]);
const [files, setFiles] = useState<StoredFile[]>([]);
const load = useCallback(async () => {
const current = await getFolder(id);
if ( !current ) {
router.back();
return;
}
setFolder(current);
setSubfolders((await getFolderFolders(id)).filter(f => f.exists));
setFiles((await getFiles(id)).filter(f => f.exists));
}, [id, router]);
useFocusEffect(
useCallback(() => {
load();
}, [load])
);
const rows: Row[] = [
...subfolders.map((f) => ({
key: f.resource_id,
kind: 'folder' as const,
label: f.name,
meta: i18n.t('folder'),
resourceId: f.resource_id,
})),
...files.map((f) => ({
key: f.resource_id,
kind: 'file' as const,
label: f.name,
meta: formatSize(f.size),
resourceId: f.resource_id,
})),
];
return (
<View style={styles.container}>
<Stack.Screen options={{ title: folder?.name ?? i18n.t('folder') }} />
<FlatList
data={rows}
keyExtractor={(item) => item.key}
renderItem={({ item }) => (
<Pressable
style={styles.row}
onPress={() => item.kind === 'folder' && router.push(`/folder/${item.resourceId}`)}
>
<Text style={item.kind === 'folder' ? styles.folderTitle : styles.fileTitle}>
{item.kind === 'folder' ? `${item.label}/` : item.label}
</Text>
<Text style={styles.rowMeta}>{item.meta}</Text>
</Pressable>
)}
ListEmptyComponent={
<Text style={styles.empty}>
{i18n.t('empty_folder')}
</Text>
}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
padding: 16,
},
row: {
paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#ddd',
},
folderTitle: {
fontSize: 16,
fontWeight: '600',
color: '#1a73e8',
},
fileTitle: {
fontSize: 16,
},
rowMeta: {
fontSize: 12,
color: '#888',
marginTop: 2,
},
empty: {
color: '#888',
textAlign: 'center',
marginTop: 24,
},
});
-200
View File
@@ -1,200 +0,0 @@
import { Stack, useFocusEffect } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { Ionicons } from '@expo/vector-icons';
import { useCallback, useState } from 'react';
import { Pressable, SectionList, StyleSheet, Text, View } from 'react-native';
import { syncRoot } from '../features/syncDevice';
import { pickDirectory } from '../services/safDirectory';
import { getFiles, saveDirectory } from '../services/localStorage';
import type { StoredFile } from '../services/db/types';
import FloatingNavBar from '../components/FloatingNavBar';
import i18n from '../i18n';
type FilePair = {
key: string;
left: StoredFile;
right: StoredFile | null;
};
type FileSection = {
key: string;
dayLabel: string;
data: FilePair[];
};
function formatSize(bytes: number): string {
if ( bytes < 1024 ) return `${bytes} ${i18n.t('bytes')}`;
if ( bytes < 1024 * 1024 ) return `${(bytes / 1024).toFixed(1)} ${i18n.t('kilobytes')}`;
return `${(bytes / (1024 * 1024)).toFixed(1)} ${i18n.t('megabytes')}`;
}
function formatDay(timestamp: number): string {
return new Intl.DateTimeFormat(i18n.locale, { day: 'numeric', month: 'long', year: 'numeric' }).format(timestamp);
}
function startOfDay(timestamp: number): number {
const date = new Date(timestamp);
date.setHours(0, 0, 0, 0);
return date.getTime();
}
function chunkToPairs(files: StoredFile[]): FilePair[] {
const pairs: FilePair[] = [];
for ( let i = 0; i < files.length; i += 2 ) {
pairs.push({
key: files[i].resource_id + (files[i + 1]?.resource_id ?? ''),
left: files[i],
right: files[i + 1] ?? null,
});
}
return pairs;
}
function groupFilesByDay(files: StoredFile[]): FileSection[] {
const byDay = new Map<number, StoredFile[]>();
for ( const file of files ) {
const day = startOfDay(file.addedAt);
const bucket = byDay.get(day);
if ( bucket ) bucket.push(file);
else byDay.set(day, [file]);
}
return [...byDay.entries()]
.sort((a, b) => b[0] - a[0])
.map(([day, data]) => ({
key: String(day),
dayLabel: formatDay(day),
data: chunkToPairs(data.sort((a, b) => b.addedAt - a.addedAt)),
}));
}
function FileCard({ file }: { file: StoredFile }) {
return (
<View style={styles.card}>
<Ionicons name="document-outline" size={28} color="#1a73e8" />
<Text style={styles.cardTitle} numberOfLines={1}>{file.name}</Text>
<Text style={styles.cardMeta}>{formatSize(file.size)}</Text>
</View>
);
}
export default function Index() {
const [sections, setSections] = useState<FileSection[]>([]);
const load = useCallback(async () => {
const files = (await getFiles()).filter(f => f.exists);
setSections(groupFilesByDay(files));
}, []);
useFocusEffect(
useCallback(() => {
load();
}, [load])
);
const handlePickDirectory = async () => {
const folder = await pickDirectory();
if ( !folder ) return;
const saved = await saveDirectory(folder);
try {
const result = await syncRoot(saved.resource_id);
console.info('walk', JSON.stringify(result));
} catch (error) {
console.warn('walk failed', error);
}
await load();
};
return (
<View style={styles.container}>
<Stack.Screen options={{ title: i18n.t('files') }} />
<StatusBar style="auto" />
<Pressable style={styles.button} onPress={handlePickDirectory}>
<Text style={styles.buttonText}>{i18n.t('add_folder')}</Text>
</Pressable>
<SectionList
sections={sections}
keyExtractor={(item) => item.key}
renderSectionHeader={({ section }) => (
<Text style={styles.sectionHeader}>{section.dayLabel}</Text>
)}
renderItem={({ item }) => (
<View style={styles.row}>
<FileCard file={item.left} />
{item.right ? <FileCard file={item.right} /> : <View style={styles.cardSpacer} />}
</View>
)}
stickySectionHeadersEnabled
contentContainerStyle={styles.listContent}
ListEmptyComponent={
<Text style={styles.empty}>
{i18n.t('no_files_yet')}
</Text>
}
/>
<FloatingNavBar />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
button: {
backgroundColor: '#1a73e8',
paddingVertical: 12,
borderRadius: 8,
alignItems: 'center',
margin: 16,
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
listContent: {
paddingHorizontal: 16,
paddingBottom: 110,
},
row: {
flexDirection: 'row',
gap: 12,
marginBottom: 12,
},
sectionHeader: {
fontSize: 14,
fontWeight: '600',
color: '#6b7280',
textTransform: 'uppercase',
marginBottom: 12,
backgroundColor: '#fff',
},
card: {
flex: 1,
backgroundColor: '#f7f8fa',
borderRadius: 10,
padding: 12,
borderWidth: StyleSheet.hairlineWidth,
borderColor: '#eaeaea',
},
cardSpacer: {
flex: 1,
},
cardTitle: {
fontSize: 14,
fontWeight: '500',
marginTop: 8,
},
cardMeta: {
fontSize: 12,
color: '#888',
marginTop: 2,
},
empty: {
color: '#888',
textAlign: 'center',
marginTop: 24,
},
});
-162
View File
@@ -1,162 +0,0 @@
import { useRouter } from 'expo-router';
import { useState } from 'react';
import {
Alert,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { useAuth } from '../context/AuthContext';
import { ApiError } from '../api/client';
import i18n from '../i18n';
export default function Login() {
const router = useRouter();
const { signIn, user, continueWithoutAccount } = useAuth();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async () => {
if (!username.trim() || !password) {
setError(i18n.t('login_error_required'));
return;
}
setSubmitting(true);
setError(null);
// Changer de compte en étant connecté remplace la session locale (garde-fou).
if (user) {
const confirmed = await new Promise<boolean>((resolve) => {
Alert.alert(i18n.t('login_switch_title'), i18n.t('login_switch_message'), [
{ text: i18n.t('login_switch_cancel'), style: 'cancel', onPress: () => resolve(false) },
{ text: i18n.t('login_switch_confirm'), style: 'destructive', onPress: () => resolve(true) },
]);
});
if (!confirmed) {
setSubmitting(false);
return;
}
}
try {
await signIn(username.trim(), password);
router.replace('/');
} catch (err) {
setError(err instanceof ApiError ? err.message : i18n.t('login_error_generic'));
} finally {
setSubmitting(false);
}
};
return (
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView
contentContainerStyle={styles.container}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
>
<Text style={styles.title}>{i18n.t('login_subtitle')}</Text>
<TextInput
style={styles.input}
placeholder={i18n.t('login_username')}
autoCapitalize="none"
autoCorrect={false}
value={username}
onChangeText={setUsername}
/>
<TextInput
style={styles.input}
placeholder={i18n.t('login_password')}
secureTextEntry
value={password}
onChangeText={setPassword}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Pressable
style={[styles.button, submitting && styles.buttonDisabled]}
onPress={handleSubmit}
disabled={submitting}
>
<Text style={styles.buttonText}>
{submitting ? i18n.t('login_submitting') : i18n.t('login_submit')}
</Text>
</Pressable>
<Pressable
style={styles.skipButton}
onPress={() => {
continueWithoutAccount();
router.replace('/');
}}
>
<Text style={styles.skipButtonText}>{i18n.t('login_skip')}</Text>
</Pressable>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
flex: {
flex: 1,
},
container: {
flexGrow: 1,
backgroundColor: '#fff',
padding: 24,
justifyContent: 'center',
gap: 12,
},
title: {
fontSize: 18,
fontWeight: '600',
marginBottom: 8,
textAlign: 'center',
},
input: {
borderWidth: StyleSheet.hairlineWidth,
borderColor: '#ccc',
borderRadius: 8,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 15,
},
error: {
color: '#c5221f',
fontSize: 14,
},
button: {
backgroundColor: '#1a73e8',
paddingVertical: 12,
borderRadius: 8,
alignItems: 'center',
marginTop: 4,
},
buttonDisabled: {
opacity: 0.6,
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
skipButton: {
alignItems: 'center',
paddingVertical: 10,
marginTop: 4,
},
skipButtonText: {
color: '#1a73e8',
fontSize: 15,
fontWeight: '500',
},
});
-22
View File
@@ -1,22 +0,0 @@
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { StyleSheet, View } from 'react-native';
import FloatingNavBar from '../components/FloatingNavBar';
import i18n from '../i18n';
export default function Search() {
return (
<View style={styles.container}>
<Stack.Screen options={{ title: i18n.t('search') }} />
<StatusBar style="auto" />
<FloatingNavBar />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
});
-22
View File
@@ -1,22 +0,0 @@
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { StyleSheet, View } from 'react-native';
import FloatingNavBar from '../components/FloatingNavBar';
import i18n from '../i18n';
export default function Settings() {
return (
<View style={styles.container}>
<Stack.Screen options={{ title: i18n.t('configuration') }} />
<StatusBar style="auto" />
<FloatingNavBar />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

-6
View File
@@ -1,6 +0,0 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
};
};
-143
View File
@@ -1,143 +0,0 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet, Platform } from 'react-native';
import { useRouter, usePathname } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import i18n from "../i18n"
import { useSafeAreaInsets } from 'react-native-safe-area-context';
type TabKey = 'files' | 'search' | 'settings';
interface TabItem {
key: TabKey;
labelKey: string;
route: string;
iconActive: keyof typeof Ionicons.glyphMap;
iconInactive: keyof typeof Ionicons.glyphMap;
}
const TABS: TabItem[] = [
{
key: 'files',
labelKey: 'nav.files', // "Fichiers"
route: '/',
iconActive: 'folder',
iconInactive: 'folder-outline',
},
{
key: 'search',
labelKey: 'nav.search', // "Recherche"
route: '/search',
iconActive: 'search',
iconInactive: 'search-outline',
},
{
key: 'settings',
labelKey: 'nav.settings', // "Configuration"
route: '/settings',
iconActive: 'settings',
iconInactive: 'settings-outline',
},
];
export default function FloatingNavBar() {
const router = useRouter();
const pathname = usePathname();
const insets = useSafeAreaInsets();
return (
<View style={[styles.wrapper, { bottom: Math.max(insets.bottom, 16) }]}>
<View style={styles.container}>
{TABS.map((tab) => {
const isActive = pathname === tab.route;
return (
<Pressable
key={tab.key}
style={({ pressed }) => [
styles.tab,
pressed && styles.tabPressed,
]}
onPress={() => {
if (!isActive) {
router.push(tab.route as any);
}
}}
>
<Ionicons
name={isActive ? tab.iconActive : tab.iconInactive}
size={22}
color={isActive ? '#1a73e8' : '#6b7280'}
/>
<Text
style={[
styles.label,
isActive ? styles.labelActive : styles.labelInactive,
]}
numberOfLines={1}
>
{i18n.t(tab.labelKey)}
</Text>
</Pressable>
);
})}
</View>
</View>
);
}
const styles = StyleSheet.create({
wrapper: {
position: 'absolute',
left: 20,
right: 20,
alignItems: 'center',
zIndex: 100,
},
container: {
flexDirection: 'row',
backgroundColor: '#ffffff',
borderRadius: 30,
paddingVertical: 8,
paddingHorizontal: 12,
width: '100%',
maxWidth: 400,
justifyContent: 'space-around',
alignItems: 'center',
// Effet d'ombre / Flottant
...Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.12,
shadowRadius: 10,
},
android: {
elevation: 8,
},
}),
borderWidth: StyleSheet.hairlineWidth,
borderColor: '#eaeaea',
},
tab: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 4,
},
tabPressed: {
opacity: 0.7,
},
label: {
fontSize: 11,
marginTop: 3,
fontWeight: '500',
},
labelActive: {
color: '#1a73e8',
fontWeight: '700',
},
labelInactive: {
color: '#6b7280',
},
});
-110
View File
@@ -1,110 +0,0 @@
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { useSyncDevice } from '../features/syncDevice';
import { api, setAuthToken, setUnauthorizedHandler } from '../api/client';
import {
clearActiveUserId,
getDeviceUserId,
setActiveUserId,
} from '../services/localStorage';
import {
clearStoredSession,
getStoredAccount,
getStoredToken,
setStoredSession,
} from '../services/secureStore';
import type { AuthContextValue, AuthStatus, User } from './AuthContext.types';
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [deviceUserId, setDeviceUserId] = useState<string | null>(null);
const [status, setStatus] = useState<AuthStatus>('loading');
const signOut = async () => {
await Promise.allSettled([clearStoredSession(), clearActiveUserId()]);
setAuthToken(null);
setUser(null);
setStatus('signedOut');
};
useEffect(() => {
let active = true;
const bootstrap = async () => {
const id = await getDeviceUserId();
if (active) setDeviceUserId(id);
try {
// Enregistrement du device (idempotent côté serveur) — obligatoire
// avant le login (INVALID_DEVICE_ID sinon). Aucun token émis ici.
await api.registerDevice(id);
} catch (error) {
console.warn('device registration failed (offline?)', error);
}
try {
const [token, storedUser] = await Promise.all([getStoredToken(), getStoredAccount()]);
if (active && token && storedUser) {
setAuthToken(token);
setActiveUserId(storedUser.id);
setUser(storedUser);
setStatus('signedIn');
return;
}
} catch (error) {
console.warn('could not restore session', error);
}
if (active) setStatus('signedOut');
};
bootstrap();
// Boucle de sync en arrière-plan (décalée, annulée au unmount).
const stopSync = useSyncDevice();
return () => {
active = false;
stopSync();
};
}, []);
// 401 sur un endpoint protégé (token expiré/révoqué, compte supprimé) →
// purge de la session. Le 401 du login est exonéré côté client (option
// skipUnauthorizedHandling dans api/client.ts).
useEffect(() => {
setUnauthorizedHandler(() => {
void signOut();
});
return () => setUnauthorizedHandler(null);
}, [signOut]);
const signIn = async (username: string, password: string) => {
const id = deviceUserId ?? (await getDeviceUserId());
const result = await api.login(username, password, id);
await setStoredSession(result.data.token, result.data.user);
await setActiveUserId(result.data.user.id);
setAuthToken(result.data.token);
setUser(result.data.user);
setStatus('signedIn');
};
const continueWithoutAccount = () => {
setStatus('local');
};
const value = useMemo(
() => ({ user, deviceUserId, status, signIn, signOut, continueWithoutAccount }),
[user, deviceUserId, status, signIn, signOut, continueWithoutAccount],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error('useAuth must be used within an AuthProvider');
}
return ctx;
}
-14
View File
@@ -1,14 +0,0 @@
import type { User } from '../api/types';
export type { User };
export type AuthStatus = 'loading' | 'signedOut' | 'local' | 'signedIn';
export type AuthContextValue = {
user: User | null;
deviceUserId: string | null;
status: AuthStatus;
signIn: (username: string, password: string) => Promise<void>;
signOut: () => Promise<void>;
continueWithoutAccount: () => void;
};
-195
View File
@@ -1,195 +0,0 @@
import { listEntries, listFoldersChunked, yieldToMainThread } from '../services/safWalk';
import type { FileEntry } from '../services/safDirectory.types';
import {
checkpointDatabase,
getFiles,
getFolder,
getFolders,
saveFile,
saveFolder,
getUserPreferences,
withTransaction,
type StoredFile,
type StoredFolder,
} from '../services/db';
import type { SyncResult } from './syncDevice.types';
import { pushPendingOps, refreshPermissions } from './syncOutbox';
function uriDepth(uri: string): number {
return uri.split('/').length;
}
function dirname(uri: string): string {
return uri.slice(0, uri.lastIndexOf('/'));
}
function storedToEntry(stored: StoredFile): FileEntry {
return {
uri: stored.uri ?? '',
name: stored.name,
isDirectory: false,
extension: stored.extension,
exists: false,
size: stored.size,
type: stored.type,
lastModified: stored.lastModified,
};
}
function isChildOf(uri: string, rootUri: string): boolean {
return uri === rootUri || uri.startsWith(rootUri + '/');
}
// 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 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 === 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);
}
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, 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 },
);
missing++;
}
}
for (const file of await getFiles()) {
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,
});
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[]> {
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 {
let stopped = false;
const tick = async () => {
try {
const preferences = await getUserPreferences();
if (preferences.syncMode === 'none') return;
const results = await syncDevice();
console.info('syncDevice', JSON.stringify(results));
// Puis pousser l'outbox (si un token est disponible) et rafraîchir le
// cache des permissions (delta).
const { pushed, retried } = await pushPendingOps();
if (pushed > 0 || retried > 0) {
console.info('pushPendingOps', JSON.stringify({ pushed, retried }));
}
const perms = await refreshPermissions();
if (perms > 0) {
console.info('refreshPermissions', perms);
}
} catch (error) {
console.warn('syncDevice failed, retrying later', error);
}
};
const loop = async () => {
while (!stopped) {
await new Promise((resolve) => setTimeout(resolve, intervalMs));
if (stopped) return;
await tick();
}
};
// Premier cycle différé : laisser le boot et l'écran actif répondre avant
// de lancer un walk SAF (potentiellement long) en arrière-plan.
const first = setTimeout(() => {
void loop();
}, 2000);
return () => {
stopped = true;
clearTimeout(first);
};
}
-6
View File
@@ -1,6 +0,0 @@
export type SyncResult = {
rootUri: string;
folders: number;
files: number;
missing: number;
};
-108
View File
@@ -1,108 +0,0 @@
import { api, hasAuthToken, ApiError } from '../api/client';
import type { SyncOperation } from '../api/types';
import {
getActiveUserId,
listQueuedOperations,
markPendingOperation,
scheduleRetries,
saveResourcePermission,
MAX_PENDING_ATTEMPTS,
} from '../services/db';
import type { PendingOperation } from '../services/db/types';
// Nombre max d'opérations par push (pour ne pas dépasser la taille du body).
const SYNC_BATCH_SIZE = 50;
// Délai minimal avant de réessayer un batch complet après une erreur transitoire
// (réseau, proxy, 5xx). Aucun compteur d'attempts n'est incrémenté pour éviter
// 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,
ref_type: op.refType,
ref_id: op.refId,
resource_id: op.resourceId ?? '',
resource_type: op.resourceType ?? 'file',
operation: op.operation,
payload: (typeof op.payload === 'object' && op.payload !== null ? op.payload : {}) as Record<string, unknown>,
};
}
// ---- outbox push -----------------------------------------------------------
export type PushResult = { pushed: number; retried: number };
export async function pushPendingOps(): Promise<PushResult> {
if (!hasAuthToken()) return { pushed: 0, retried: 0 };
const queued = await listQueuedOperations(SYNC_BATCH_SIZE);
if (queued.length === 0) return { pushed: 0, retried: 0 };
let result;
try {
result = (await api.syncOps(queued.map(toSyncOperation))).data;
} 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.
const applied = Math.min(result.applied, queued.length);
for (const op of queued.slice(0, applied)) {
await markPendingOperation(op.id, 'completed');
}
if (result.failed && applied < queued.length) {
const failed = queued[applied];
await markPendingOperation(failed.id, 'failed', `${result.failed.code}: ${result.failed.message}`);
// Les opérations [applied+1, length) restent pending : le serveur ne les
// a pas reçues (il s'arrête à l'index) et elles seront resoumises au
// prochain tick.
}
return { pushed: applied, retried: 0 };
}
// ---- permissions snapshot --------------------------------------------------
// Delta monotone PAR COMPTE (clé = active_user_id, NULL hors compte) : la
// valeur max de cached_at du dernier snapshot sert de borne `after` pour le
// prochain appel. Le snapshot n'est pas rejoué quand on change de compte.
const lastPermissionCachedAtByUser = new Map<string | null, number>();
export async function refreshPermissions(): Promise<number> {
if (!hasAuthToken()) return 0;
const activeUserId = await getActiveUserId();
const after = lastPermissionCachedAtByUser.get(activeUserId) ?? undefined;
const perms = await api.getSyncPermissions(after);
if (perms.data.length === 0) return 0;
for (const p of perms.data) {
await saveResourcePermission(p);
}
lastPermissionCachedAtByUser.set(activeUserId, Math.max(...perms.data.map((p) => p.cachedAt)));
return perms.data.length;
}
// Visible uniquement pour les tests unitaires (reset de l'état en mémoire).
export function resetPermissionCachedAtForTests(): void {
lastPermissionCachedAtByUser.clear();
}
-31
View File
@@ -1,31 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '../api/client';
import type { FileDto, ListFilesParams } from '../api/types';
export function useFiles(params?: ListFilesParams) {
return useQuery({
queryKey: ['files', params],
queryFn: () => api.listFiles(params),
});
}
export function useFile(id: string) {
return useQuery({
queryKey: ['files', id],
queryFn: () => api.getFile(id),
enabled: id.length > 0,
});
}
export function useFileTags(id: string) {
return useQuery({
queryKey: ['files', id, 'tags'],
queryFn: async (): Promise<string[]> => {
const { data } = await api.getFile(id);
return data.tags ?? [];
},
enabled: id.length > 0,
});
}
export type { FileDto };
-14
View File
@@ -1,14 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '../api/client';
import type { FileDto } from '../api/types';
export function useSearch(q: string, page?: number, pageSize?: number) {
const trimmed = q.trim();
return useQuery({
queryKey: ['search', trimmed, page, pageSize],
queryFn: () => api.searchFiles(trimmed, { page, pageSize }),
enabled: trimmed.length > 0,
});
}
export type { FileDto };
-40
View File
@@ -1,40 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '../api/client';
import type { FileDto, OcrJob } from '../api/types';
export function useUpload() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: {
file: { uri: string; name: string; mimeType: string };
folderId?: string | null;
}) => api.uploadFile(input.file, input.folderId),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['files'] });
queryClient.invalidateQueries({ queryKey: ['search'] });
return result;
},
});
}
export function useOcrJob(jobId?: string | null) {
return useQuery({
queryKey: ['ocr', jobId],
queryFn: () => api.getOcrJob(jobId!),
enabled: Boolean(jobId),
refetchInterval: (query) =>
query.state.data && query.state.data.data.status === 'done' ? false : 3000,
});
}
export function useCreateOcrJob() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (fileId: string) => api.createOcrJob(fileId),
onSuccess: (result) => {
queryClient.setQueryData(['ocr', result.data.id], result);
},
});
}
export type { FileDto, OcrJob };
-32
View File
@@ -1,32 +0,0 @@
{
"files": "Files",
"search": "Search",
"configuration": "Settings",
"nav": {
"files": "Files",
"search": "Search",
"settings": "Settings"
},
"add_folder": "Add a folder",
"no_folders_yet": "No folders yet. Tap \"Add a folder\" to sync a folder.",
"no_files_yet": "No files yet. Tap \"Add a folder\" to sync your files.",
"folder": "Folder",
"empty_folder": "Empty folder — next syncDevice will refresh it.",
"bytes": "B",
"kilobytes": "KB",
"megabytes": "MB",
"login_title": "Sign in",
"login_subtitle": "Sign in to your account",
"login_username": "Username",
"login_password": "Password",
"login_submit": "Sign in",
"login_submitting": "Signing in…",
"login_error_required": "Username and password are required",
"login_error_generic": "Could not sign in right now",
"login_switch_title": "Account already signed in",
"login_switch_message": "This sign-in will replace the active account on this device. Continue?",
"login_switch_confirm": "Replace",
"login_switch_cancel": "Cancel",
"login_skip": "Continue without account",
"skip_message": "Local mode — your folders stay on this device and are not synced."
}
-32
View File
@@ -1,32 +0,0 @@
{
"files": "Fichiers",
"search": "Recherche",
"configuration": "Configuration",
"nav": {
"files": "Fichiers",
"search": "Recherche",
"settings": "Réglages"
},
"add_folder": "Ajouter un dossier",
"no_folders_yet": "Aucun dossier pour le moment. Appuie sur « Ajouter un dossier » pour synchroniser un dossier.",
"no_files_yet": "Aucun fichier pour le moment. Appuie sur « Ajouter un dossier » pour synchroniser tes fichiers.",
"folder": "Dossier",
"empty_folder": "Dossier vide — le prochain syncDevice l'actualisera.",
"bytes": "o",
"kilobytes": "Ko",
"megabytes": "Mo",
"login_title": "Connexion",
"login_subtitle": "Connecte-toi à ton compte",
"login_username": "Nom d'utilisateur",
"login_password": "Mot de passe",
"login_submit": "Se connecter",
"login_submitting": "Connexion…",
"login_error_required": "Nom d'utilisateur et mot de passe requis",
"login_error_generic": "Impossible de se connecter pour le moment",
"login_switch_title": "Compte déjà connecté",
"login_switch_message": "Cette connexion remplacera le compte actif sur cet appareil. Continuer ?",
"login_switch_confirm": "Remplacer",
"login_switch_cancel": "Annuler",
"login_skip": "Continuer sans compte",
"skip_message": "Mode local — tes dossiers restent sur cet appareil et ne sont pas synchronisés."
}
-12
View File
@@ -1,12 +0,0 @@
import { I18n } from 'i18n-js';
import { getLocales } from 'expo-localization';
import fr from './fr.json';
import en from './en.json';
const i18n = new I18n({ fr, en });
i18n.defaultLocale = 'fr';
i18n.enableFallback = true;
i18n.locale = getLocales()[0]?.languageCode ?? 'fr';
export default i18n;
-7
View File
@@ -1,7 +0,0 @@
// Learn more https://docs.expo.io/guides/customizing-metro
const { getDefaultConfig } = require('expo/metro-config');
/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname);
module.exports = config;
-8186
View File
File diff suppressed because it is too large Load Diff
-47
View File
@@ -1,47 +0,0 @@
{
"name": "webui",
"version": "2.0.0",
"main": "expo-router/entry",
"dependencies": {
"@expo/vector-icons": "^15.1.1",
"@tanstack/react-query": "^5.102.8",
"expo": "~57.0.8",
"expo-constants": "~57.0.17",
"expo-file-system": "~57.0.6",
"expo-linking": "~57.0.9",
"expo-localization": "~57.0.1",
"expo-router": "~57.0.20",
"expo-secure-store": "~57.0.3",
"expo-sqlite": "~57.0.2",
"expo-status-bar": "~57.0.1",
"i18n-js": "^4.5.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.86.0",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0"
},
"devDependencies": {
"@types/better-sqlite3": "^9.6.0",
"@types/react": "~19.2.2",
"babel-preset-expo": "^57.0.4",
"better-sqlite3": "^13.0.3",
"tsx": "^4.23.13",
"typescript": "~6.0.3"
},
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"test:db": "tsx --test tests/migrations.test.ts tests/repositories.test.ts tests/dbClient.test.ts",
"test:migrations": "tsx --test tests/migrations.test.ts",
"test:api": "tsx --test tests/apiClient.test.ts",
"test:sync": "tsx --test tests/syncOutbox.test.ts",
"test:saf": "tsx --test tests/safWalk.test.ts",
"test:syncDevice": "tsx --test tests/syncDevice.test.ts",
"test:e2e": "tsx --test tests/e2e.live.test.ts",
"test": "npm run test:db && npm run test:api && npm run test:sync && npm run test:saf && npm run test:syncDevice"
},
"private": true
}
-131
View File
@@ -1,131 +0,0 @@
import type { SQLiteDatabase } from 'expo-sqlite';
import { migrateDatabase } from './migrations';
import { DATABASE_NAME } from './schema';
let database: SQLiteDatabase | null = null;
let opening: Promise<SQLiteDatabase> | null = null;
async function loadSqlite(): Promise<typeof import('expo-sqlite')> {
return import('expo-sqlite');
}
/**
* expo-sqlite (Android, encore non corrigé en 57.x, cf. expo/expo#48999) peut
* « empoisonner » une connexion : après un teardown de runtime (reload dev,
* reconstitution d'Activity) de vieux handles natifs sont double-fermés et
* chaque requête suivante rejette un NullPointerException nu. Un simple reopen
* renvoie l'objet empoisonné en cache ; seul un reconnect (useNewConnection)
* redonne une connexion vive.
*/
export function isBrokenConnectionError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
return (
error.message.includes("NativeDatabase.prepareAsync") ||
error.message.includes("NativeDatabase.execAsync") ||
error.message.includes("NullPointerException")
);
}
async function openDatabase(useNewConnection: boolean): Promise<SQLiteDatabase> {
const SQLite = await loadSqlite();
const db = await SQLite.openDatabaseAsync(
DATABASE_NAME,
useNewConnection ? { useNewConnection: true } : {},
);
await migrateDatabase(db);
return db;
}
export function getDatabase(): Promise<SQLiteDatabase> {
if (database) return Promise.resolve(database);
if (!opening) {
opening = openDatabase(false)
.then((db) => {
database = db;
return db;
})
.finally(() => {
opening = null;
});
}
return opening;
}
export async function recoverDatabase(): Promise<SQLiteDatabase> {
const poisoned = database;
database = null;
opening = null;
if (poisoned) {
try {
await poisoned.closeAsync();
} catch {
// Handle déjà corrompu : se contenter de le lâcher, le reconnect suffit.
}
}
const fresh = await openDatabase(true);
database = fresh;
return fresh;
}
export type DatabaseRetryDeps = {
get(): Promise<SQLiteDatabase>;
recover(): Promise<SQLiteDatabase>;
};
const defaultRetryDeps: DatabaseRetryDeps = {
get: getDatabase,
recover: recoverDatabase,
};
export async function withDatabaseRetry<T>(
run: (db: SQLiteDatabase) => Promise<T>,
deps: DatabaseRetryDeps = defaultRetryDeps,
): Promise<T> {
try {
const db = await deps.get();
return await run(db);
} catch (error) {
if (!isBrokenConnectionError(error)) throw error;
const fresh = await deps.recover();
return await run(fresh);
}
}
export async function closeDatabase(): Promise<void> {
if (!database) return;
await database.closeAsync();
database = null;
opening = null;
}
export async function checkpointDatabase(): Promise<void> {
try {
const db = await getDatabase();
await db.execAsync('PRAGMA wal_checkpoint(TRUNCATE)');
} catch {
// Best-effort : ne jamais faire échouer la sync sur un checkpoint.
}
}
type WithTransactionImpl = <T>(work: (db: SQLiteDatabase) => Promise<T>) => Promise<T>;
let withTransactionOverride: WithTransactionImpl | null = null;
/** Test-only seam : les tests Node n'ont pas expo-sqlite, `syncRoot`/`syncDevice`
* substituent leur transaction par un simple `work()`. `null` en production. */
export function __setWithTransactionForTests(impl: WithTransactionImpl | null): void {
withTransactionOverride = impl;
}
export async function withTransaction<T>(
work: (db: SQLiteDatabase) => Promise<T>,
): Promise<T> {
if (withTransactionOverride) return withTransactionOverride(work);
return withDatabaseRetry(async (db) => {
let result!: T;
await db.withTransactionAsync(async () => {
result = await work(db);
});
return result;
});
}
-9
View File
@@ -1,9 +0,0 @@
import { getSession } from './session';
export async function newResourceId(): Promise<string> {
const db = await getSession();
const row = await db.getFirstAsync<{ id: string }>(
'SELECT lower(hex(randomblob(16))) AS id',
);
return row!.id;
}
-51
View File
@@ -1,51 +0,0 @@
export {
getDatabase,
closeDatabase,
withTransaction,
checkpointDatabase,
} from './client';
export { migrateDatabase, type Migration, type MigrationDb } from './migrations';
export * from './repositories';
export type { AccessCheck, AccessSource } from './repositories/permissions';
export {
transitionSyncStatus,
type SyncEvent,
type SyncTransitionResult,
} from './transitions';
export type {
AccessLevel,
NewPendingOperation,
NewResourcePermission,
NewShare,
NewShareLink,
PendingOperation,
PendingOperationRefType,
PendingOperationRow,
PendingOperationStatus,
PendingOperationType,
PushStatus,
Recipient,
RecipientRow,
RecipientType,
ResourcePermission,
ResourceType,
Share,
ShareLink,
ShareLinkRow,
ShareRow,
StoredFile,
StoredFolder,
SyncStatus,
UserPreferences,
FolderRow,
FileRow,
} from './types';
export {
DATABASE_NAME,
DATABASE_VERSION,
DEVICE_USER_ID_KEY,
AUTH_TOKEN_KEY,
ACTIVE_USER_ID_KEY,
PREFERENCES_KEY,
PERMISSION_TTL_MS,
} from './schema';
-299
View File
@@ -1,299 +0,0 @@
import { DATABASE_VERSION, DEVICE_USER_ID_KEY } from './schema';
export type MigrationDb = {
execAsync(source: string): Promise<void>;
getFirstAsync<T>(source: string, ...params: unknown[]): Promise<T | null>;
withExclusiveTransactionAsync(task: (txn: MigrationDb) => Promise<void>): Promise<void>;
};
export type Migration = {
version: number;
up: (db: MigrationDb) => Promise<void>;
};
export const MIGRATIONS: Migration[] = [
{
version: 1,
up: async (db) => {
await db.execAsync(`
PRAGMA journal_mode = 'wal';
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS folders (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
uri TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
"exists" INTEGER,
sync_status TEXT NOT NULL DEFAULT 'local',
added_at INTEGER NOT NULL,
parent_uri TEXT
);
CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
uri TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
folder_uri TEXT NOT NULL,
extension TEXT,
size INTEGER,
"type" TEXT,
"exists" INTEGER,
last_modified INTEGER,
sync_status TEXT NOT NULL DEFAULT 'local',
added_at INTEGER NOT NULL,
FOREIGN KEY (folder_uri) REFERENCES folders(uri) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_preferences (
"key" TEXT PRIMARY KEY NOT NULL,
"value" TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
`);
},
},
{
version: 2,
up: async (db) => {
await db.execAsync(`
CREATE INDEX IF NOT EXISTS idx_files_folder_uri ON files(folder_uri);
`);
},
},
{
version: 3,
up: async (db) => {
await db.execAsync(`
CREATE TABLE IF NOT EXISTS resource_permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
resource_id TEXT NOT NULL,
resource_type TEXT NOT NULL CHECK (resource_type IN ('folder', 'file')),
effective_access TEXT NOT NULL CHECK (effective_access IN ('owner', 'editor', 'commenter', 'viewer')),
inherit INTEGER NOT NULL DEFAULT 1,
owner_id TEXT,
shared_by_id TEXT,
expires_at INTEGER,
cached_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE (resource_id, resource_type)
);
CREATE TABLE IF NOT EXISTS shares (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
resource_id TEXT NOT NULL,
resource_type TEXT NOT NULL CHECK (resource_type IN ('folder', 'file')),
recipient_type TEXT NOT NULL CHECK (recipient_type IN ('user', 'group')),
recipient_id TEXT NOT NULL,
relation TEXT NOT NULL CHECK (relation IN ('owner', 'editor', 'commenter', 'viewer')),
inherit INTEGER NOT NULL DEFAULT 1,
expires_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE (resource_id, resource_type, recipient_type, recipient_id)
);
CREATE TABLE IF NOT EXISTS recipients (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
recipient_type TEXT NOT NULL CHECK (recipient_type IN ('user', 'group')),
recipient_id TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS share_links (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
token TEXT NOT NULL UNIQUE,
resource_id TEXT NOT NULL,
resource_type TEXT NOT NULL CHECK (resource_type IN ('folder', 'file')),
has_password INTEGER NOT NULL DEFAULT 0,
allow_download INTEGER NOT NULL DEFAULT 1,
expires_at INTEGER,
max_downloads INTEGER,
downloads_count INTEGER NOT NULL DEFAULT 0,
is_revoked INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
CHECK (max_downloads IS NULL OR max_downloads >= 0),
CHECK (downloads_count >= 0)
);
CREATE TABLE IF NOT EXISTS pending_operations (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
resource_id TEXT,
resource_type TEXT CHECK (resource_type IN ('folder', 'file')),
ref_type TEXT CHECK (ref_type IN ('resource', 'share', 'share_link')),
ref_id INTEGER,
operation TEXT NOT NULL CHECK (operation IN (
'create_resource', 'update_metadata', 'delete_resource', 'move_resource',
'share', 'revoke_share', 'update_share',
'create_link', 'revoke_link'
)),
payload TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'in_progress', 'completed', 'failed', 'cancelled')),
attempts INTEGER NOT NULL DEFAULT 0,
error TEXT,
created_at INTEGER NOT NULL,
next_retry_at INTEGER,
last_error_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_pending_queue ON pending_operations(created_at) WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS idx_pending_resource ON pending_operations(resource_id);
`);
await db.execAsync(
`INSERT OR IGNORE INTO user_preferences ("key", "value", updated_at)
VALUES ('${DEVICE_USER_ID_KEY}', lower(hex(randomblob(16))), ${Date.now()});`,
);
},
},
{
version: 4,
up: async (db) => {
await db.execAsync('PRAGMA foreign_keys = OFF;');
await db.withExclusiveTransactionAsync(async (txn) => {
await txn.execAsync(`
CREATE TABLE folders_new (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
resource_id TEXT NOT NULL UNIQUE,
uri TEXT,
name TEXT NOT NULL,
"exists" INTEGER,
parent_resource_id TEXT REFERENCES folders_new(resource_id) ON DELETE CASCADE,
owner_id TEXT NOT NULL,
sync_status TEXT NOT NULL DEFAULT 'local',
added_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
`);
await txn.execAsync(`
INSERT INTO folders_new (id, resource_id, uri, name, "exists", parent_resource_id, owner_id, sync_status, added_at, updated_at)
SELECT id, lower(hex(randomblob(16))), uri, name, "exists", NULL,
(SELECT "value" FROM user_preferences WHERE "key" = '${DEVICE_USER_ID_KEY}'),
sync_status, added_at, added_at FROM folders;
`);
await txn.execAsync(`
UPDATE folders_new SET parent_resource_id = (
SELECT parent_new.resource_id
FROM folders old_child
JOIN folders old_parent ON old_parent.uri = old_child.parent_uri
JOIN folders_new parent_new ON parent_new.uri = old_parent.uri
WHERE old_child.uri = folders_new.uri
);
`);
await txn.execAsync('DROP TABLE folders;');
await txn.execAsync('ALTER TABLE folders_new RENAME TO folders;');
await txn.execAsync(`
CREATE TABLE files_new (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
resource_id TEXT NOT NULL UNIQUE,
uri TEXT,
name TEXT NOT NULL,
folder_resource_id TEXT NOT NULL REFERENCES folders(resource_id) ON DELETE CASCADE,
extension TEXT,
size INTEGER,
"type" TEXT,
"exists" INTEGER,
last_modified INTEGER,
owner_id TEXT NOT NULL,
sync_status TEXT NOT NULL DEFAULT 'local',
added_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
`);
await txn.execAsync(`
INSERT INTO files_new (id, resource_id, uri, name, folder_resource_id, extension, size, "type", "exists", last_modified, owner_id, sync_status, added_at, updated_at)
SELECT f.id, lower(hex(randomblob(16))), f.uri, f.name,
fn.resource_id,
f.extension, f.size, f."type", f."exists", f.last_modified,
(SELECT "value" FROM user_preferences WHERE "key" = '${DEVICE_USER_ID_KEY}'),
f.sync_status, f.added_at, f.added_at
FROM files f
JOIN folders fn ON fn.uri = f.folder_uri;
`);
await txn.execAsync('DROP TABLE files;');
await txn.execAsync('ALTER TABLE files_new RENAME TO files;');
await txn.execAsync(`
CREATE INDEX IF NOT EXISTS idx_folders_resource_id ON folders(resource_id);
CREATE INDEX IF NOT EXISTS idx_files_resource_id ON files(resource_id);
CREATE INDEX IF NOT EXISTS idx_files_folder_resource ON files(folder_resource_id);
CREATE INDEX IF NOT EXISTS idx_folders_parent ON folders(parent_resource_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_folders_uri ON folders(uri) WHERE uri IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_files_uri ON files(uri) WHERE uri IS NOT NULL;
`);
await txn.execAsync(`PRAGMA user_version = 4;`);
});
await db.execAsync('PRAGMA foreign_keys = ON;');
},
},
{
// User-first : pending_operations et resource_permissions deviennent
// scopés par compte (`user_id`), NULL = entrées legacy/device-local. Le
// UNIQUE de resource_permissions passe à (user_id, resource_id,
// resource_type) pour qu'un même fichier partagé à deux comptes ne
// collisionne pas côté cache. SQLite ne pouvant pas altérer un UNIQUE, la
// table est reconstruite (le schéma v5 se base sur v3/v4 — l'indice
// UNIQUE (resource_id, resource_type) a été posé en v3).
version: 5,
up: async (db) => {
await db.execAsync('PRAGMA foreign_keys = OFF;');
await db.withExclusiveTransactionAsync(async (txn) => {
await txn.execAsync(`
CREATE TABLE resource_permissions_new (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
user_id TEXT,
resource_id TEXT NOT NULL,
resource_type TEXT NOT NULL CHECK (resource_type IN ('folder', 'file')),
effective_access TEXT NOT NULL CHECK (effective_access IN ('owner', 'editor', 'commenter', 'viewer')),
inherit INTEGER NOT NULL DEFAULT 1,
owner_id TEXT,
shared_by_id TEXT,
expires_at INTEGER,
cached_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE (user_id, resource_id, resource_type)
);
INSERT INTO resource_permissions_new (id, user_id, resource_id, resource_type, effective_access, inherit, owner_id, shared_by_id, expires_at, cached_at, updated_at)
SELECT id, NULL, resource_id, resource_type, effective_access, inherit, owner_id, shared_by_id, expires_at, cached_at, updated_at FROM resource_permissions;
DROP TABLE resource_permissions;
ALTER TABLE resource_permissions_new RENAME TO resource_permissions;
CREATE INDEX IF NOT EXISTS idx_permissions_resource ON resource_permissions(resource_id, resource_type);
`);
await txn.execAsync(`
ALTER TABLE pending_operations ADD COLUMN user_id TEXT;
`);
await txn.execAsync(`PRAGMA user_version = 5;`);
});
await db.execAsync('PRAGMA foreign_keys = ON;');
},
},
];
async function readUserVersion(db: MigrationDb): Promise<number> {
const row = await db.getFirstAsync<{ user_version: number }>('PRAGMA user_version');
return row?.user_version ?? 0;
}
async function writeUserVersion(db: MigrationDb, version: number): Promise<void> {
await db.execAsync(`PRAGMA user_version = ${version}`);
}
const migrationPromises = new WeakMap<MigrationDb, Promise<void>>();
export async function migrateDatabase(
db: MigrationDb,
targetVersion: number = DATABASE_VERSION,
): Promise<void> {
const existing = migrationPromises.get(db);
if (existing) return existing;
const promise = runMigrations(db, targetVersion).finally(() => {
migrationPromises.delete(db);
});
migrationPromises.set(db, promise);
return promise;
}
async function runMigrations(db: MigrationDb, targetVersion: number): Promise<void> {
let currentVersion = await readUserVersion(db);
for (const migration of MIGRATIONS) {
if (migration.version > currentVersion && migration.version <= targetVersion) {
await migration.up(db);
await writeUserVersion(db, migration.version);
currentVersion = migration.version;
}
}
}
-114
View File
@@ -1,114 +0,0 @@
import { getSession } from '../session';
import { newResourceId } from '../id';
import { FILE_COLUMNS_SQL } from '../schema';
import { getDeviceUserId } from './preferences';
import type { FileEntry, FileRow, StoredFile, SyncStatus } from '../types';
export type SaveFileOptions = {
syncStatus?: SyncStatus;
resource_id?: string;
};
export async function saveFile(
file: FileEntry,
folderResourceId: string,
options: SaveFileOptions = {},
): Promise<StoredFile> {
const db = await getSession();
const now = Date.now();
const exists = file.exists ? 1 : 0;
const ownerId = await getDeviceUserId();
const existing = options.resource_id
? await db.getFirstAsync<FileRow>(
`SELECT ${FILE_COLUMNS_SQL} FROM files WHERE resource_id = ?`,
options.resource_id,
)
: await db.getFirstAsync<FileRow>(
`SELECT ${FILE_COLUMNS_SQL} FROM files WHERE uri = ?`,
file.uri,
);
const resourceId = existing?.resource_id ?? (await newResourceId());
const baseSync = existing?.sync_status ?? options.syncStatus ?? 'local';
await db.runAsync(
`INSERT INTO files
(resource_id, uri, name, folder_resource_id, extension, size, "type", "exists", last_modified, owner_id, sync_status, added_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(resource_id) DO UPDATE SET
uri = excluded.uri,
name = excluded.name,
folder_resource_id = excluded.folder_resource_id,
extension = excluded.extension,
size = excluded.size,
"type" = excluded."type",
"exists" = excluded."exists",
last_modified = excluded.last_modified,
sync_status = excluded.sync_status,
updated_at = excluded.updated_at`,
resourceId,
file.uri,
file.name,
folderResourceId,
file.extension ?? null,
file.size,
file.type ?? null,
exists,
file.lastModified ?? null,
ownerId,
baseSync,
existing?.added_at ?? now,
now,
);
const row = await db.getFirstAsync<FileRow>(
`SELECT ${FILE_COLUMNS_SQL} FROM files WHERE resource_id = ?`,
resourceId,
);
return toStoredFile(row!);
}
export async function getFiles(folderResourceId?: string): Promise<StoredFile[]> {
const db = await getSession();
const rows =
folderResourceId === undefined
? await db.getAllAsync<FileRow>(`SELECT ${FILE_COLUMNS_SQL} FROM files ORDER BY name ASC`)
: await db.getAllAsync<FileRow>(
`SELECT ${FILE_COLUMNS_SQL} FROM files WHERE folder_resource_id = ? ORDER BY name ASC`,
folderResourceId,
);
return rows.map(toStoredFile);
}
export async function getFile(resourceId: string): Promise<StoredFile | null> {
const db = await getSession();
const row = await db.getFirstAsync<FileRow>(
`SELECT ${FILE_COLUMNS_SQL} FROM files WHERE resource_id = ?`,
resourceId,
);
return row ? toStoredFile(row) : null;
}
export async function removeFile(resourceId: string): Promise<void> {
const db = await getSession();
await db.runAsync('DELETE FROM files WHERE resource_id = ?', resourceId);
}
function toStoredFile(row: FileRow): StoredFile {
return {
resource_id: row.resource_id,
uri: row.uri,
name: row.name,
folder_resource_id: row.folder_resource_id,
extension: row.extension ?? '',
exists: row.exists === 1,
size: row.size,
type: row.type ?? '',
lastModified: row.last_modified,
owner_id: row.owner_id,
syncStatus: row.sync_status,
addedAt: row.added_at,
updatedAt: row.updated_at,
};
}
-129
View File
@@ -1,129 +0,0 @@
import { getSession } from '../session';
import { newResourceId } from '../id';
import { FOLDER_COLUMNS_SQL } from '../schema';
import { getDeviceUserId } from './preferences';
import type { FolderRow, StoredFolder, SyncStatus } from '../types';
export type SaveFolderInput = {
uri: string | null;
name: string;
exists?: boolean;
resource_id?: string;
};
export type SaveFolderOptions = {
parentResourceId?: string | null;
syncStatus?: SyncStatus;
};
export async function saveFolder(
input: SaveFolderInput,
options: SaveFolderOptions = {},
): Promise<StoredFolder> {
const db = await getSession();
const now = Date.now();
const exists =
input.exists === undefined || input.exists === null ? null : input.exists ? 1 : 0;
const ownerId = await getDeviceUserId();
const existing = input.resource_id
? await db.getFirstAsync<FolderRow>(
`SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE resource_id = ?`,
input.resource_id,
)
: input.uri
? await db.getFirstAsync<FolderRow>(
`SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE uri = ?`,
input.uri,
)
: null;
const resourceId = existing?.resource_id ?? input.resource_id ?? (await newResourceId());
const baseSync = existing?.sync_status ?? options.syncStatus ?? 'local';
await db.runAsync(
`INSERT INTO folders
(resource_id, uri, name, "exists", parent_resource_id, owner_id, sync_status, added_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(resource_id) DO UPDATE SET
uri = excluded.uri,
name = excluded.name,
"exists" = excluded."exists",
parent_resource_id = CASE
WHEN excluded.parent_resource_id IS NULL THEN folders.parent_resource_id
ELSE excluded.parent_resource_id
END,
sync_status = excluded.sync_status,
updated_at = excluded.updated_at`,
resourceId,
input.uri,
input.name,
exists,
options.parentResourceId ?? null,
ownerId,
baseSync,
existing?.added_at ?? now,
now,
);
const row = await db.getFirstAsync<FolderRow>(
`SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE resource_id = ?`,
resourceId,
);
return toStoredFolder(row!);
}
export async function saveDirectory(folder: {
uri: string;
name: string;
exists?: boolean;
}): Promise<StoredFolder> {
return saveFolder({ uri: folder.uri, name: folder.name, exists: folder.exists });
}
export async function getFolders(): Promise<StoredFolder[]> {
const db = await getSession();
const rows = await db.getAllAsync<FolderRow>(
`SELECT ${FOLDER_COLUMNS_SQL} FROM folders ORDER BY name ASC`,
);
return rows.map(toStoredFolder);
}
export async function getFolderFolders(
parentResourceId: string | null,
): Promise<StoredFolder[]> {
const db = await getSession();
const rows = await db.getAllAsync<FolderRow>(
`SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE parent_resource_id IS ? ORDER BY name ASC`,
parentResourceId,
);
return rows.map(toStoredFolder);
}
export async function getFolder(resourceId: string): Promise<StoredFolder | null> {
const db = await getSession();
const row = await db.getFirstAsync<FolderRow>(
`SELECT ${FOLDER_COLUMNS_SQL} FROM folders WHERE resource_id = ?`,
resourceId,
);
return row ? toStoredFolder(row) : null;
}
export async function removeFolder(resourceId: string): Promise<void> {
const db = await getSession();
await db.runAsync('DELETE FROM folders WHERE resource_id = ?', resourceId);
}
function toStoredFolder(row: FolderRow): StoredFolder {
return {
resource_id: row.resource_id,
uri: row.uri,
name: row.name,
exists: row.exists === 1,
parent_resource_id: row.parent_resource_id,
owner_id: row.owner_id,
syncStatus: row.sync_status,
addedAt: row.added_at,
updatedAt: row.updated_at,
};
}
-46
View File
@@ -1,46 +0,0 @@
export {
saveFolder,
saveDirectory,
getFolders,
getFolderFolders,
getFolder,
removeFolder,
} from './folders';
export { saveFile, getFiles, getFile, removeFile } from './files';
export {
saveUserPreferences,
getUserPreferences,
getDeviceUserId,
getDeviceAuthToken,
saveDeviceAuthToken,
getActiveUserId,
setActiveUserId,
clearActiveUserId,
} from './preferences';
export {
getResourcePermission,
saveResourcePermission,
canAccess,
canWrite,
isOwner,
} from './permissions';
export { saveShare, getShare, getShares, removeShare, removeSharesForResource } from './shares';
export {
createShareLink,
getShareLinkById,
getShareLinkByToken,
getShareLinks,
incrementLinkDownloads,
revokeShareLink,
removeShareLinksForResource,
} from './shareLinks';
export { saveRecipient, getRecipients, setRecipientActive } from './recipients';
export {
enqueuePendingOperation,
getPendingOperations,
getNextQueuedOperation,
listQueuedOperations,
scheduleRetries,
markPendingOperation,
MAX_PENDING_ATTEMPTS,
} from './pendingOps';
@@ -1,183 +0,0 @@
import { getSession } from '../session';
import { getActiveUserId } from './preferences';
import type {
NewPendingOperation,
PendingOperation,
PendingOperationRow,
PendingOperationStatus,
} from '../types';
const PENDING_OPERATION_COLUMNS = `
id, resource_id, resource_type, ref_type, ref_id, operation, payload,
status, attempts, error, created_at, next_retry_at, last_error_at`;
const MAX_BACKOFF_MS = 24 * 60 * 60 * 1000;
const BASE_BACKOFF_MS = 30 * 1000;
export const MAX_PENDING_ATTEMPTS = 5;
export async function enqueuePendingOperation(
operation: NewPendingOperation,
): Promise<number> {
const db = await getSession();
const activeUserId = await getActiveUserId();
const row = await db.getFirstAsync<{ id: number }>(
`INSERT INTO pending_operations
(resource_id, resource_type, ref_type, ref_id, operation, payload, status, attempts, created_at, user_id)
VALUES (?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?)
RETURNING id`,
operation.resourceId ?? null,
operation.resourceType ?? null,
operation.refType ?? null,
operation.refId ?? null,
operation.operation,
JSON.stringify(operation.payload ?? {}),
Date.now(),
activeUserId,
);
return row!.id;
}
export async function getPendingOperations(
status?: PendingOperationStatus,
): Promise<PendingOperation[]> {
const db = await getSession();
const rows = status
? await db.getAllAsync<PendingOperationRow>(
`SELECT ${PENDING_OPERATION_COLUMNS} FROM pending_operations
WHERE status = ? AND (user_id = ? OR user_id IS NULL)
ORDER BY created_at ASC, id ASC`,
status,
await getActiveUserId(),
)
: await db.getAllAsync<PendingOperationRow>(
`SELECT ${PENDING_OPERATION_COLUMNS} FROM pending_operations
WHERE user_id = ? OR user_id IS NULL
ORDER BY created_at ASC, id ASC`,
await getActiveUserId(),
);
return rows.map(toPendingOperation);
}
export async function getNextQueuedOperation(): Promise<PendingOperation | null> {
const db = await getSession();
const row = await db.getFirstAsync<PendingOperationRow>(
`SELECT ${PENDING_OPERATION_COLUMNS} FROM pending_operations
WHERE status = 'pending'
AND (user_id = ? OR user_id IS NULL)
AND (next_retry_at IS NULL OR next_retry_at <= ?)
ORDER BY created_at ASC, id ASC
LIMIT 1`,
await getActiveUserId(),
Date.now(),
);
return row ? toPendingOperation(row) : null;
}
// Même chose mais en batch (tri FIFO identique) — utilisé par pushPendingOps.
export async function listQueuedOperations(limit: number): Promise<PendingOperation[]> {
if (limit <= 0) return [];
const db = await getSession();
const rows = await db.getAllAsync<PendingOperationRow>(
`SELECT ${PENDING_OPERATION_COLUMNS} FROM pending_operations
WHERE status = 'pending'
AND (user_id = ? OR user_id IS NULL)
AND (next_retry_at IS NULL OR next_retry_at <= ?)
ORDER BY created_at ASC, id ASC
LIMIT ?`,
await getActiveUserId(),
Date.now(),
limit,
);
return rows.map(toPendingOperation);
}
// Reporter des opérations sans incrémenter attempts (utilisé en cas d'erreur
// transitoire réseau/serveur, pour éviter les dead-letters prématurées).
export async function scheduleRetries(ids: number[], retryAt: number): Promise<void> {
const db = await getSession();
const now = Date.now();
for (const id of ids) {
await db.runAsync(
`UPDATE pending_operations SET next_retry_at = ?, last_error_at = ? WHERE id = ?`,
retryAt,
now,
id,
);
}
}
export async function markPendingOperation(
id: number,
status: PendingOperationStatus,
error?: string | null,
): Promise<void> {
const db = await getSession();
const now = Date.now();
if (status === 'failed') {
const current = await db.getFirstAsync<{ attempts: number }>(
'SELECT attempts FROM pending_operations WHERE id = ?',
id,
);
if (!current) return;
const attempts = current.attempts + 1;
if (attempts >= MAX_PENDING_ATTEMPTS) {
await db.runAsync(
`UPDATE pending_operations SET
status = 'failed', attempts = ?, error = ?, next_retry_at = NULL, last_error_at = ?
WHERE id = ?`,
attempts,
error ?? null,
now,
id,
);
return;
}
const backoff = Math.min(BASE_BACKOFF_MS * 2 ** attempts, MAX_BACKOFF_MS);
await db.runAsync(
`UPDATE pending_operations SET
status = 'pending', attempts = ?, error = ?, next_retry_at = ?, last_error_at = ?
WHERE id = ?`,
attempts,
error ?? null,
now + backoff,
now,
id,
);
return;
}
await db.runAsync(
`UPDATE pending_operations SET status = ?, error = ?, next_retry_at = NULL, last_error_at = NULL WHERE id = ?`,
status,
status === 'in_progress' ? null : error ?? null,
id,
);
}
function toPendingOperation(row: PendingOperationRow): PendingOperation {
return {
id: row.id,
resourceId: row.resource_id,
resourceType: row.resource_type,
refType: row.ref_type,
refId: row.ref_id,
operation: row.operation,
payload: safeParse(row.payload),
status: row.status,
attempts: row.attempts,
error: row.error,
createdAt: row.created_at,
nextRetryAt: row.next_retry_at,
lastErrorAt: row.last_error_at,
};
}
function safeParse(json: string): unknown {
try {
return JSON.parse(json);
} catch {
return {};
}
}
@@ -1,249 +0,0 @@
import type { DbSession } from '../session';
import { getSession } from '../session';
import { PERMISSION_TTL_MS } from '../schema';
import { getActiveUserId, getDeviceUserId } from './preferences';
import type {
AccessLevel,
NewResourcePermission,
ResourcePermission,
ResourcePermissionRow,
ResourceType,
} from '../types';
const ACCESS_RANK: Record<AccessLevel, number> = {
viewer: 1,
commenter: 2,
editor: 3,
owner: 4,
};
export type AccessSource = 'none' | 'cache' | 'owner' | 'inherited';
export type AccessCheck = {
allowed: boolean;
access: AccessLevel | null;
source: AccessSource;
stale: boolean;
expiresAt: number | null;
};
type LineageNode = {
resource_id: string;
resource_type: ResourceType;
parent_resource_id: string | null;
owner_id: string;
};
function rank(level: AccessLevel): number {
return ACCESS_RANK[level];
}
export async function getResourcePermission(
resourceId: string,
resourceType: ResourceType,
): Promise<ResourcePermission | null> {
const db = await getSession();
const row = await db.getFirstAsync<ResourcePermissionRow>(
`SELECT * FROM resource_permissions
WHERE resource_id = ? AND resource_type = ? AND (user_id = ? OR user_id IS NULL)`,
resourceId,
resourceType,
await getActiveUserId(),
);
return row ? toResourcePermission(row) : null;
}
export async function saveResourcePermission(
permission: NewResourcePermission,
): Promise<void> {
const db = await getSession();
const now = Date.now();
await db.runAsync(
`INSERT INTO resource_permissions
(user_id, resource_id, resource_type, effective_access, inherit, owner_id, shared_by_id, expires_at, cached_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id, resource_id, resource_type) DO UPDATE SET
effective_access = excluded.effective_access,
inherit = excluded.inherit,
owner_id = excluded.owner_id,
shared_by_id = excluded.shared_by_id,
expires_at = excluded.expires_at,
cached_at = excluded.cached_at,
updated_at = excluded.updated_at`,
await getActiveUserId(),
permission.resource_id,
permission.resourceType,
permission.effectiveAccess,
permission.inherit === false ? 0 : 1,
permission.ownerId ?? null,
permission.sharedById ?? null,
permission.expiresAt ?? null,
now,
now,
);
}
async function resourceLineage(
resourceId: string,
resourceType: ResourceType,
): Promise<LineageNode[]> {
const db = await getSession();
if (resourceType === 'file') {
const file = await db.getFirstAsync<{
folder_resource_id: string;
owner_id: string;
}>(
'SELECT folder_resource_id, owner_id FROM files WHERE resource_id = ?',
resourceId,
);
if (!file) return [];
return [
{
resource_id: resourceId,
resource_type: 'file',
parent_resource_id: file.folder_resource_id,
owner_id: file.owner_id,
},
...(await folderLineage(db, file.folder_resource_id)),
];
}
return folderLineage(db, resourceId);
}
async function folderLineage(
db: DbSession,
startResourceId: string,
): Promise<LineageNode[]> {
const rows = await db.getAllAsync<LineageNode>(
`WITH RECURSIVE lineage(resource_id, parent_resource_id, owner_id) AS (
SELECT resource_id, parent_resource_id, owner_id FROM folders WHERE resource_id = ?
UNION ALL
SELECT f.resource_id, f.parent_resource_id, f.owner_id
FROM folders f
JOIN lineage l ON f.resource_id = l.parent_resource_id
)
SELECT resource_id, 'folder' AS resource_type, parent_resource_id, owner_id FROM lineage`,
startResourceId,
);
return rows;
}
function isStale(cachedAt: number, now: number): boolean {
return now - cachedAt > PERMISSION_TTL_MS;
}
function readOnlyAccess(permission: ResourcePermission, now: number): AccessLevel {
return isStale(permission.cachedAt, now) ? 'viewer' : permission.effectiveAccess;
}
export async function canAccess(
resourceId: string,
resourceType: ResourceType,
required: AccessLevel,
): Promise<AccessCheck> {
const now = Date.now();
// Le device est le propriétaire local de ses propres ressources (device_user_id
// seedé en owner_id, cf. v4). Owner = device-local : le check reste volontairement
// indépendant du compte connecté (fallback hors-cloud, décision offline).
const deviceUserId = await getDeviceUserId();
const exactCache = await getResourcePermission(resourceId, resourceType);
if (exactCache) {
if (exactCache.ownerId === deviceUserId) {
return { allowed: true, access: 'owner', source: 'owner', stale: false, expiresAt: null };
}
if (exactCache.expiresAt != null && exactCache.expiresAt < now) {
return {
allowed: false,
access: exactCache.effectiveAccess,
source: 'cache',
stale: false,
expiresAt: exactCache.expiresAt,
};
}
const stale = isStale(exactCache.cachedAt, now);
const applyAccess = readOnlyAccess(exactCache, now);
return {
allowed: rank(applyAccess) >= rank(required),
access: applyAccess,
source: 'cache',
stale,
expiresAt: exactCache.expiresAt,
};
}
const lineage = await resourceLineage(resourceId, resourceType);
let best: {
access: AccessLevel;
source: AccessSource;
stale: boolean;
expiresAt: number | null;
} | null = null;
for (const [index, node] of lineage.entries()) {
if (node.owner_id === deviceUserId) {
return { allowed: true, access: 'owner', source: 'owner', stale: false, expiresAt: null };
}
const nodePermission = await getResourcePermission(node.resource_id, node.resource_type);
if (!nodePermission) continue;
if (nodePermission.expiresAt != null && nodePermission.expiresAt < now) continue;
if (index > 0 && nodePermission.inherit === false) continue;
const stale = isStale(nodePermission.cachedAt, now);
const candidate = {
access: readOnlyAccess(nodePermission, now),
source: (index === 0 ? 'cache' : 'inherited') as AccessSource,
stale,
expiresAt: nodePermission.expiresAt,
};
if (
!best ||
rank(candidate.access) > rank(best.access) ||
(rank(candidate.access) === rank(best.access) && !candidate.stale && best.stale)
) {
best = candidate;
}
}
if (!best) {
return { allowed: false, access: null, source: 'none', stale: false, expiresAt: null };
}
return {
allowed: rank(best.access) >= rank(required),
access: best.access,
source: best.source,
stale: best.stale,
expiresAt: best.expiresAt,
};
}
export async function canWrite(
resourceId: string,
resourceType: ResourceType,
): Promise<AccessCheck> {
return canAccess(resourceId, resourceType, 'editor');
}
export async function isOwner(
resourceId: string,
resourceType: ResourceType,
): Promise<boolean> {
const check = await canAccess(resourceId, resourceType, 'owner');
return check.allowed;
}
function toResourcePermission(row: ResourcePermissionRow): ResourcePermission {
return {
resource_id: row.resource_id,
resourceType: row.resource_type,
effectiveAccess: row.effective_access,
inherit: row.inherit === 1,
ownerId: row.owner_id,
sharedById: row.shared_by_id,
expiresAt: row.expires_at,
cachedAt: row.cached_at,
updatedAt: row.updated_at,
};
}
@@ -1,104 +0,0 @@
import { getSession } from '../session';
import { ACTIVE_USER_ID_KEY, AUTH_TOKEN_KEY, DEVICE_USER_ID_KEY, PREFERENCES_KEY } from '../schema';
import type { UserPreferences } from '../types';
const DEFAULT_PREFERENCES: UserPreferences = {
syncMode: 'full',
};
export async function getDeviceUserId(): Promise<string> {
const db = await getSession();
const row = await db.getFirstAsync<{ value: string }>(
'SELECT "value" FROM user_preferences WHERE "key" = ?',
DEVICE_USER_ID_KEY,
);
if (row) return row.value;
await db.runAsync(
`INSERT OR IGNORE INTO user_preferences ("key", "value", updated_at)
VALUES (?, lower(hex(randomblob(16))), ?)`,
DEVICE_USER_ID_KEY,
Date.now(),
);
const seeded = await db.getFirstAsync<{ value: string }>(
'SELECT "value" FROM user_preferences WHERE "key" = ?',
DEVICE_USER_ID_KEY,
);
return seeded!.value;
}
export async function saveUserPreferences(preferences: UserPreferences): Promise<void> {
const db = await getSession();
await db.runAsync(
`INSERT INTO user_preferences ("key", "value", updated_at) VALUES (?, ?, ?)
ON CONFLICT("key") DO UPDATE SET "value" = excluded."value", updated_at = excluded.updated_at`,
PREFERENCES_KEY,
JSON.stringify(preferences),
Date.now(),
);
}
export async function getDeviceAuthToken(): Promise<string | null> {
const db = await getSession();
const row = await db.getFirstAsync<{ value: string }>(
'SELECT "value" FROM user_preferences WHERE "key" = ?',
AUTH_TOKEN_KEY,
);
return row?.value ?? null;
}
export async function saveDeviceAuthToken(token: string): Promise<void> {
const db = await getSession();
await db.runAsync(
`INSERT INTO user_preferences ("key", "value", updated_at) VALUES (?, ?, ?)
ON CONFLICT("key") DO UPDATE SET "value" = excluded."value", updated_at = excluded.updated_at`,
AUTH_TOKEN_KEY,
token,
Date.now(),
);
}
// Miroir non-sensible du compte connecté (le token vit en SecureStore). NULL
// = aucun compte actif (mode device-local legacy / tests).
export async function getActiveUserId(): Promise<string | null> {
const db = await getSession();
const row = await db.getFirstAsync<{ value: string }>(
'SELECT "value" FROM user_preferences WHERE "key" = ?',
ACTIVE_USER_ID_KEY,
);
return row?.value || null;
}
export async function setActiveUserId(userId: string): Promise<void> {
const db = await getSession();
await db.runAsync(
`INSERT INTO user_preferences ("key", "value", updated_at) VALUES (?, ?, ?)
ON CONFLICT("key") DO UPDATE SET "value" = excluded."value", updated_at = excluded.updated_at`,
ACTIVE_USER_ID_KEY,
userId,
Date.now(),
);
}
export async function clearActiveUserId(): Promise<void> {
const db = await getSession();
await db.runAsync(
'DELETE FROM user_preferences WHERE "key" = ?',
ACTIVE_USER_ID_KEY,
);
}
export async function getUserPreferences(): Promise<UserPreferences> {
const db = await getSession();
const row = await db.getFirstAsync<{ value: string }>(
'SELECT "value" FROM user_preferences WHERE "key" = ?',
PREFERENCES_KEY,
);
if (!row) return DEFAULT_PREFERENCES;
try {
return { ...DEFAULT_PREFERENCES, ...JSON.parse(row.value) } satisfies UserPreferences;
} catch {
return DEFAULT_PREFERENCES;
}
}
@@ -1,64 +0,0 @@
import { getSession } from '../session';
import type { Recipient, RecipientRow, RecipientType } from '../types';
export async function saveRecipient(
recipientType: RecipientType,
recipientId: string,
displayName: string,
): Promise<Recipient> {
const db = await getSession();
await db.runAsync(
`INSERT INTO recipients (recipient_type, recipient_id, display_name, is_active, updated_at)
VALUES (?, ?, ?, 1, ?)
ON CONFLICT(recipient_id) DO UPDATE SET
recipient_type = excluded.recipient_type,
display_name = excluded.display_name,
is_active = 1,
updated_at = excluded.updated_at`,
recipientType,
recipientId,
displayName,
Date.now(),
);
const row = await db.getFirstAsync<RecipientRow>(
'SELECT * FROM recipients WHERE recipient_id = ?',
recipientId,
);
return toRecipient(row!);
}
export async function getRecipients(activeOnly = true): Promise<Recipient[]> {
const db = await getSession();
const rows = activeOnly
? await db.getAllAsync<RecipientRow>(
'SELECT * FROM recipients WHERE is_active = 1 ORDER BY display_name ASC',
)
: await db.getAllAsync<RecipientRow>('SELECT * FROM recipients ORDER BY display_name ASC');
return rows.map(toRecipient);
}
export async function setRecipientActive(
recipientType: RecipientType,
recipientId: string,
active: boolean,
): Promise<void> {
const db = await getSession();
await db.runAsync(
`UPDATE recipients SET is_active = ?, updated_at = ?
WHERE recipient_type = ? AND recipient_id = ?`,
active ? 1 : 0,
Date.now(),
recipientType,
recipientId,
);
}
function toRecipient(row: RecipientRow): Recipient {
return {
recipientType: row.recipient_type,
recipientId: row.recipient_id,
displayName: row.display_name,
isActive: row.is_active === 1,
updatedAt: row.updated_at,
};
}
@@ -1,131 +0,0 @@
import { getSession } from '../session';
import type { NewShareLink, ResourceType, ShareLink, ShareLinkRow } from '../types';
const PUSH_STATUS_SQL = `(
SELECT CASE
WHEN EXISTS (
SELECT 1 FROM pending_operations p
WHERE p.ref_type = 'share_link' AND p.ref_id = sl.id AND p.status IN ('pending', 'in_progress')
) THEN 'pending'
WHEN EXISTS (
SELECT 1 FROM pending_operations p
WHERE p.ref_type = 'share_link' AND p.ref_id = sl.id AND p.status = 'failed'
) THEN 'failed'
ELSE 'synced'
END
) AS push_status`;
export async function createShareLink(input: NewShareLink): Promise<ShareLink> {
const db = await getSession();
const now = Date.now();
let token = input.token;
if (!token) {
const tokenRow = await db.getFirstAsync<{ token: string }>(
'SELECT lower(hex(randomblob(16))) AS token',
);
token = tokenRow!.token;
}
const row = await db.getFirstAsync<{ id: number }>(
`INSERT INTO share_links
(token, resource_id, resource_type, has_password, allow_download, expires_at, max_downloads, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id`,
token,
input.resourceId,
input.resourceType,
input.hasPassword ? 1 : 0,
input.allowDownload === false ? 0 : 1,
input.expiresAt ?? null,
input.maxDownloads ?? null,
now,
now,
);
const created = await getShareLinkById(row!.id);
if (!created) throw new Error('share link insert failed');
return created;
}
export async function getShareLinkById(id: number): Promise<ShareLink | null> {
const db = await getSession();
const row = await db.getFirstAsync<ShareLinkRow & { push_status: ShareLink['pushStatus'] }>(
`SELECT sl.*, ${PUSH_STATUS_SQL} FROM share_links sl WHERE sl.id = ?`,
id,
);
return row ? toShareLink(row) : null;
}
export async function getShareLinkByToken(token: string): Promise<ShareLink | null> {
const db = await getSession();
const row = await db.getFirstAsync<ShareLinkRow & { push_status: ShareLink['pushStatus'] }>(
`SELECT sl.*, ${PUSH_STATUS_SQL} FROM share_links sl WHERE sl.token = ?`,
token,
);
return row ? toShareLink(row) : null;
}
export async function getShareLinks(
resourceId?: string,
resourceType?: ResourceType,
): Promise<ShareLink[]> {
const db = await getSession();
let sql = `SELECT sl.*, ${PUSH_STATUS_SQL} FROM share_links sl`;
const params: string[] = [];
if (resourceId) {
sql += ' WHERE sl.resource_id = ?';
params.push(resourceId);
if (resourceType) {
sql += ' AND sl.resource_type = ?';
params.push(resourceType);
}
}
sql += ' ORDER BY sl.created_at ASC';
const rows = await db.getAllAsync<ShareLinkRow & { push_status: ShareLink['pushStatus'] }>(sql, ...params);
return rows.map(toShareLink);
}
export async function incrementLinkDownloads(id: number): Promise<void> {
const db = await getSession();
await db.runAsync(
`UPDATE share_links SET downloads_count = downloads_count + 1, updated_at = ? WHERE id = ?`,
Date.now(),
id,
);
}
export async function revokeShareLink(id: number): Promise<void> {
const db = await getSession();
await db.runAsync(
`UPDATE share_links SET is_revoked = 1, updated_at = ? WHERE id = ?`,
Date.now(),
id,
);
}
export async function removeShareLinksForResource(
resourceId: string,
resourceType: ResourceType,
): Promise<void> {
const db = await getSession();
await db.runAsync('DELETE FROM share_links WHERE resource_id = ? AND resource_type = ?', resourceId, resourceType);
}
function toShareLink(row: ShareLinkRow & { push_status: ShareLink['pushStatus'] }): ShareLink {
return {
id: row.id,
token: row.token,
resourceId: row.resource_id,
resourceType: row.resource_type,
hasPassword: row.has_password === 1,
allowDownload: row.allow_download === 1,
expiresAt: row.expires_at,
maxDownloads: row.max_downloads,
downloadsCount: row.downloads_count,
isRevoked: row.is_revoked === 1,
createdAt: row.created_at,
updatedAt: row.updated_at,
pushStatus: row.push_status,
};
}
-130
View File
@@ -1,130 +0,0 @@
import { getSession } from '../session';
import type { NewShare, ResourceType, Share, ShareRow } from '../types';
const PUSH_STATUS_SQL = `(
SELECT CASE
WHEN EXISTS (
SELECT 1 FROM pending_operations p
WHERE p.ref_type = 'share' AND p.ref_id = s.id AND p.status IN ('pending', 'in_progress')
) THEN 'pending'
WHEN EXISTS (
SELECT 1 FROM pending_operations p
WHERE p.ref_type = 'share' AND p.ref_id = s.id AND p.status = 'failed'
) THEN 'failed'
ELSE 'synced'
END
) AS push_status`;
export async function saveShare(share: NewShare): Promise<Share> {
const db = await getSession();
const now = Date.now();
await db.runAsync(
`INSERT INTO shares
(resource_id, resource_type, recipient_type, recipient_id, relation, inherit, expires_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(resource_id, resource_type, recipient_type, recipient_id) DO UPDATE SET
relation = excluded.relation,
inherit = excluded.inherit,
expires_at = excluded.expires_at,
updated_at = excluded.updated_at`,
share.resourceId,
share.resourceType,
share.recipientType,
share.recipientId,
share.relation,
share.inherit === false ? 0 : 1,
share.expiresAt ?? null,
now,
now,
);
const row = await db.getFirstAsync<ShareRow & { push_status: Share['pushStatus'] }>(
`SELECT s.*, ${PUSH_STATUS_SQL} FROM shares s
WHERE s.resource_id = ? AND s.resource_type = ? AND s.recipient_type = ? AND s.recipient_id = ?`,
share.resourceId,
share.resourceType,
share.recipientType,
share.recipientId,
);
return toShare(row!);
}
export async function getShare(
resourceId: string,
resourceType: ResourceType,
recipientType: 'user' | 'group',
recipientId: string,
): Promise<Share | null> {
const db = await getSession();
const row = await db.getFirstAsync<ShareRow & { push_status: Share['pushStatus'] }>(
`SELECT s.*, ${PUSH_STATUS_SQL} FROM shares s
WHERE s.resource_id = ? AND s.resource_type = ? AND s.recipient_type = ? AND s.recipient_id = ?`,
resourceId,
resourceType,
recipientType,
recipientId,
);
return row ? toShare(row) : null;
}
export async function getShares(
resourceId?: string,
resourceType?: ResourceType,
): Promise<Share[]> {
const db = await getSession();
let sql = `SELECT s.*, ${PUSH_STATUS_SQL} FROM shares s`;
const params: string[] = [];
if (resourceId) {
sql += ' WHERE s.resource_id = ?';
params.push(resourceId);
if (resourceType) {
sql += ' AND s.resource_type = ?';
params.push(resourceType);
}
}
sql += ' ORDER BY s.created_at ASC';
const rows = await db.getAllAsync<ShareRow & { push_status: Share['pushStatus'] }>(sql, ...params);
return rows.map(toShare);
}
export async function removeShare(
resourceId: string,
resourceType: ResourceType,
recipientType: 'user' | 'group',
recipientId: string,
): Promise<void> {
const db = await getSession();
await db.runAsync(
`DELETE FROM shares
WHERE resource_id = ? AND resource_type = ? AND recipient_type = ? AND recipient_id = ?`,
resourceId,
resourceType,
recipientType,
recipientId,
);
}
export async function removeSharesForResource(
resourceId: string,
resourceType: ResourceType,
): Promise<void> {
const db = await getSession();
await db.runAsync('DELETE FROM shares WHERE resource_id = ? AND resource_type = ?', resourceId, resourceType);
}
function toShare(row: ShareRow & { push_status: Share['pushStatus'] }): Share {
return {
id: row.id,
resourceId: row.resource_id,
resourceType: row.resource_type,
recipientType: row.recipient_type,
recipientId: row.recipient_id,
relation: row.relation,
inherit: row.inherit === 1,
expiresAt: row.expires_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
pushStatus: row.push_status,
};
}
-49
View File
@@ -1,49 +0,0 @@
export const DATABASE_NAME = 'dot.db';
export const DATABASE_VERSION = 5;
export const PREFERENCES_KEY = 'user_preferences';
export const DEVICE_USER_ID_KEY = 'device_user_id';
export const AUTH_TOKEN_KEY = 'auth_token';
// Miroir non-sensible du compte connecté (voir services/secureStore.ts) : le
// token lui-même reste en SecureStore ; seul l'id du compte actif est répété en
// SQLite pour permettre aux repositories de scoper leurs lectures/écritures
// sans avoir à importer expo-secure-store (tests Node inclus).
export const ACTIVE_USER_ID_KEY = 'active_user_id';
export const FOLDER_COLUMNS = [
'resource_id',
'uri',
'name',
'"exists"',
'sync_status',
'added_at',
'updated_at',
'parent_resource_id',
'owner_id',
] as const;
export const FOLDER_COLUMNS_SQL = FOLDER_COLUMNS.join(', ');
export const FILE_COLUMNS = [
'resource_id',
'uri',
'name',
'folder_resource_id',
'extension',
'size',
'"type"',
'"exists"',
'last_modified',
'sync_status',
'added_at',
'updated_at',
'owner_id',
] as const;
export const FILE_COLUMNS_SQL = FILE_COLUMNS.join(', ');
export const PERMISSION_TTL_MS = 24 * 60 * 60 * 1000;
-29
View File
@@ -1,29 +0,0 @@
import { getDatabase, withDatabaseRetry } from './client';
import type { SQLiteBindValue } from 'expo-sqlite';
export type DbSession = {
runAsync(sql: string, ...params: SQLiteBindValue[]): Promise<unknown>;
getFirstAsync<T>(sql: string, ...params: SQLiteBindValue[]): Promise<T | null>;
getAllAsync<T>(sql: string, ...params: SQLiteBindValue[]): Promise<T[]>;
};
let override: DbSession | null = null;
export function __setDbForTests(db: DbSession | null): void {
override = db;
}
export async function getSession(): Promise<DbSession> {
if (override) return override;
await getDatabase();
return liveSession;
}
const liveSession: DbSession = {
runAsync: (sql, ...params) =>
withDatabaseRetry((db) => db.runAsync(sql, ...params)),
getFirstAsync: (sql, ...params) =>
withDatabaseRetry((db) => db.getFirstAsync(sql, ...params)),
getAllAsync: (sql, ...params) =>
withDatabaseRetry((db) => db.getAllAsync(sql, ...params)),
};
-48
View File
@@ -1,48 +0,0 @@
import type { SyncStatus } from './types';
export type SyncEvent =
| 'created_local'
| 'uploaded'
| 'downloaded'
| 'deleted_cloud';
export type SyncTransitionResult = SyncStatus | 'gone';
export function transitionSyncStatus(
from: SyncStatus,
event: SyncEvent,
): SyncTransitionResult {
switch (from) {
case 'local':
switch (event) {
case 'created_local':
return 'local';
case 'uploaded':
return 'cloud';
case 'downloaded':
return 'local-cloud';
case 'deleted_cloud':
return 'gone';
}
case 'cloud':
switch (event) {
case 'created_local':
return 'local-cloud';
case 'uploaded':
return 'cloud';
case 'downloaded':
return 'local-cloud';
case 'deleted_cloud':
return 'gone';
}
case 'local-cloud':
switch (event) {
case 'created_local':
case 'uploaded':
case 'downloaded':
return 'local-cloud';
case 'deleted_cloud':
return 'local';
}
}
}
-266
View File
@@ -1,266 +0,0 @@
import type { FileEntry, Folder } from '../safDirectory.types';
export type { FileEntry, Folder } from '../safDirectory.types';
export type SyncStatus = 'local' | 'cloud' | 'local-cloud';
export type AccessLevel = 'owner' | 'editor' | 'commenter' | 'viewer';
export type ResourceType = 'folder' | 'file';
export type RecipientType = 'user' | 'group';
export type PushStatus = 'pending' | 'synced' | 'failed';
export type UserPreferences = {
syncMode: 'full' | 'manual' | 'none';
};
export type FolderRow = {
id: number;
resource_id: string;
uri: string | null;
name: string;
exists: number | null;
parent_resource_id: string | null;
owner_id: string;
sync_status: SyncStatus;
added_at: number;
updated_at: number;
};
export type FileRow = {
id: number;
resource_id: string;
uri: string | null;
name: string;
folder_resource_id: string;
extension: string | null;
size: number;
type: string | null;
exists: number;
last_modified: number | null;
owner_id: string;
sync_status: SyncStatus;
added_at: number;
updated_at: number;
};
export type StoredFolder = {
resource_id: string;
uri: string | null;
name: string;
exists: boolean;
parent_resource_id: string | null;
owner_id: string;
syncStatus: SyncStatus;
addedAt: number;
updatedAt: number;
};
export type StoredFile = {
resource_id: string;
uri: string | null;
name: string;
folder_resource_id: string;
extension: string;
exists: boolean;
size: number;
type: string;
lastModified: number | null;
owner_id: string;
syncStatus: SyncStatus;
addedAt: number;
updatedAt: number;
};
export type ResourcePermissionRow = {
id: number;
resource_id: string;
resource_type: ResourceType;
effective_access: AccessLevel;
inherit: number;
owner_id: string | null;
shared_by_id: string | null;
expires_at: number | null;
cached_at: number;
updated_at: number;
};
export type ResourcePermission = {
resource_id: string;
resourceType: ResourceType;
effectiveAccess: AccessLevel;
inherit: boolean;
ownerId: string | null;
sharedById: string | null;
expiresAt: number | null;
cachedAt: number;
updatedAt: number;
};
export type NewResourcePermission = {
resource_id: string;
resourceType: ResourceType;
effectiveAccess: AccessLevel;
inherit?: boolean;
ownerId?: string | null;
sharedById?: string | null;
expiresAt?: number | null;
};
export type ShareRow = {
id: number;
resource_id: string;
resource_type: ResourceType;
recipient_type: RecipientType;
recipient_id: string;
relation: AccessLevel;
inherit: number;
expires_at: number | null;
created_at: number;
updated_at: number;
};
export type Share = {
id: number;
resourceId: string;
resourceType: ResourceType;
recipientType: RecipientType;
recipientId: string;
relation: AccessLevel;
inherit: boolean;
expiresAt: number | null;
createdAt: number;
updatedAt: number;
pushStatus: PushStatus;
};
export type NewShare = {
resourceId: string;
resourceType: ResourceType;
recipientType: RecipientType;
recipientId: string;
relation: AccessLevel;
inherit?: boolean;
expiresAt?: number | null;
};
export type RecipientRow = {
id: number;
recipient_type: RecipientType;
recipient_id: string;
display_name: string;
is_active: number;
updated_at: number;
};
export type Recipient = {
recipientType: RecipientType;
recipientId: string;
displayName: string;
isActive: boolean;
updatedAt: number;
};
export type ShareLinkRow = {
id: number;
token: string;
resource_id: string;
resource_type: ResourceType;
has_password: number;
allow_download: number;
expires_at: number | null;
max_downloads: number | null;
downloads_count: number;
is_revoked: number;
created_at: number;
updated_at: number;
};
export type ShareLink = {
id: number;
token: string;
resourceId: string;
resourceType: ResourceType;
hasPassword: boolean;
allowDownload: boolean;
expiresAt: number | null;
maxDownloads: number | null;
downloadsCount: number;
isRevoked: boolean;
createdAt: number;
updatedAt: number;
pushStatus: PushStatus;
};
export type NewShareLink = {
token?: string;
resourceId: string;
resourceType: ResourceType;
hasPassword?: boolean;
allowDownload?: boolean;
expiresAt?: number | null;
maxDownloads?: number | null;
};
export type PendingOperationStatus =
| 'pending'
| 'in_progress'
| 'completed'
| 'failed'
| 'cancelled';
export type PendingOperationType =
| 'create_resource'
| 'update_metadata'
| 'delete_resource'
| 'move_resource'
| 'share'
| 'revoke_share'
| 'update_share'
| 'create_link'
| 'revoke_link';
export type PendingOperationRefType = 'resource' | 'share' | 'share_link';
export type PendingOperationRow = {
id: number;
resource_id: string | null;
resource_type: ResourceType | null;
ref_type: PendingOperationRefType | null;
ref_id: number | null;
operation: PendingOperationType;
payload: string;
status: PendingOperationStatus;
attempts: number;
error: string | null;
created_at: number;
next_retry_at: number | null;
last_error_at: number | null;
};
export type PendingOperation = {
id: number;
resourceId: string | null;
resourceType: ResourceType | null;
refType: PendingOperationRefType | null;
refId: number | null;
operation: PendingOperationType;
payload: unknown;
status: PendingOperationStatus;
attempts: number;
error: string | null;
createdAt: number;
nextRetryAt: number | null;
lastErrorAt: number | null;
};
export type NewPendingOperation = {
resourceId?: string | null;
resourceType?: ResourceType | null;
refType?: PendingOperationRefType | null;
refId?: number | null;
operation: PendingOperationType;
payload?: unknown;
};
-66
View File
@@ -1,66 +0,0 @@
export {
getUserPreferences,
getDeviceUserId,
getDeviceAuthToken,
saveDeviceAuthToken,
getActiveUserId,
setActiveUserId,
clearActiveUserId,
getFolders,
getFolderFolders,
getFolder,
getFiles,
getFile,
removeFolder,
removeFile,
saveFile,
saveFolder,
saveDirectory,
saveUserPreferences,
getResourcePermission,
saveResourcePermission,
canAccess,
canWrite,
isOwner,
saveShare,
getShare,
getShares,
removeShare,
removeSharesForResource,
createShareLink,
getShareLinkById,
getShareLinkByToken,
getShareLinks,
incrementLinkDownloads,
revokeShareLink,
removeShareLinksForResource,
saveRecipient,
getRecipients,
setRecipientActive,
enqueuePendingOperation,
getPendingOperations,
getNextQueuedOperation,
markPendingOperation,
transitionSyncStatus,
} from './db';
export type {
AccessLevel,
AccessCheck,
AccessSource,
StoredFile,
StoredFolder,
SyncStatus,
PushStatus,
UserPreferences,
ResourceType,
ResourcePermission,
RecipientType,
Recipient,
Share,
ShareLink,
PendingOperation,
PendingOperationStatus,
SyncEvent,
SyncTransitionResult,
} from './db';
-88
View File
@@ -1,88 +0,0 @@
import { Directory, File, Paths } from 'expo-file-system';
import type {
DirectoryEntry,
FileEntry,
Folder,
FolderInfo,
} 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: listDirectoryEntries,
info: getFolderInfo,
yield: yieldToMainThread,
});
export async function pickDirectory(initialUri?: string): Promise<Folder | null> {
try {
const directory = await Directory.pickDirectoryAsync(initialUri);
if (!directory || !directory.uri) return null;
return toFolder({ uri: directory.uri, name: directory.name, exists: directory.exists });
} catch (error) {
console.warn('[safDirectory] pickDirectory failed:', error);
return null;
}
}
export function listDirectoryEntries(directoryUri: string): DirectoryEntry[] {
const directory = new Directory(directoryUri);
if (!directory.exists) return [];
try {
return directory.list().map(toEntry);
} catch (error) {
console.warn('[safDirectory] listDirectoryEntries failed:', error);
return [];
}
}
export function getFolderInfo(directoryUri: string): FolderInfo {
const directory = new Directory(directoryUri);
return {
uri: directory.uri,
name: directory.name,
exists: directory.exists,
};
}
/** 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 });
return toFolder({ uri: directory.uri, name: directory.name, exists: directory.exists });
}
export function fileFromUri(uri: string): FileEntry {
return toFileEntry(new File(uri));
}
function toFileEntry(file: File): FileEntry {
return {
uri: file.uri,
name: file.name,
isDirectory: false,
extension: file.extension,
exists: file.exists,
size: file.size,
type: file.type,
lastModified: file.lastModified,
};
}
function toEntry(item: Directory | File): DirectoryEntry {
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 {
uri,
name: name ?? directory?.name ?? Paths.basename(uri),
isDirectory: true,
exists: exists ?? directory?.exists,
};
}
-31
View File
@@ -1,31 +0,0 @@
export type Folder = {
uri: string;
name: string;
isDirectory: true;
/** `undefined` when neither caller nor Directory fallback provided a value. */
exists?: boolean;
};
export type FileEntry = {
uri: string;
name: string;
isDirectory: false;
extension: string;
exists: boolean;
size: number;
type: string;
lastModified: number | null;
};
export type DirectoryEntry = Folder | FileEntry;
export type FolderInfo = {
uri: string;
name: string;
exists: boolean;
};
export type PickDirectoryOptions = {
recursive?: boolean;
includeRoot?: boolean;
};
-87
View File
@@ -1,87 +0,0 @@
import type {
DirectoryEntry,
Folder,
FolderInfo,
PickDirectoryOptions,
} from './safDirectory.types';
export async function yieldToMainThread(): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
export const DEFAULT_WALK_BUDGET_MS = 16;
export type SafWalkImpl = {
list: (uri: string) => DirectoryEntry[];
info: (uri: string) => FolderInfo;
yield: () => Promise<void>;
};
let walkImpl: SafWalkImpl | null = null;
export function __setSafWalkImpl(impl: SafWalkImpl): void {
walkImpl = impl;
}
export function __setSafWalkForTests(impl: Partial<SafWalkImpl>): () => void {
const previous = walkImpl;
walkImpl = { ...previous, ...impl } as SafWalkImpl;
return () => {
walkImpl = previous;
};
}
export async function listFoldersChunked(
directoryUri: string,
options: PickDirectoryOptions = {},
budgetMs = DEFAULT_WALK_BUDGET_MS,
): Promise<Folder[]> {
const impl = walkImpl;
if (!impl) throw new Error('SafWalkImpl not configured');
const { recursive = false, includeRoot = false } = options;
const result: Folder[] = [];
if (includeRoot) {
result.push({ ...impl.info(directoryUri), isDirectory: true });
}
if (!recursive) {
for (const entry of impl.list(directoryUri)) {
if (entry.isDirectory) result.push(entry);
}
return result;
}
let lastYield = Date.now();
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();
}
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]);
}
}
return result;
}
export function listEntries(directoryUri: string): DirectoryEntry[] {
const impl = walkImpl;
if (!impl) throw new Error('SafWalkImpl not configured');
return impl.list(directoryUri);
}
-42
View File
@@ -1,42 +0,0 @@
import * as SecureStore from 'expo-secure-store';
import type { User } from '../api/types';
// Clés SecureStore (iOS keychain / Android Keystore). Le token et le profil du
// compte connecté ne sont JAMAIS persistés en SQLite : un miroir non-sensible
// (active_user_id) est répété en base pour le scoping des repositories.
const TOKEN_KEY = 'vaultdrop.auth_token';
const ACCOUNT_KEY = 'vaultdrop.account';
export async function getStoredToken(): Promise<string | null> {
try {
return await SecureStore.getItemAsync(TOKEN_KEY);
} catch {
return null;
}
}
export async function getStoredAccount(): Promise<User | null> {
try {
const raw = await SecureStore.getItemAsync(ACCOUNT_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as User;
if (typeof parsed?.id !== 'string' || typeof parsed?.username !== 'string') return null;
return parsed;
} catch {
return null;
}
}
export async function setStoredSession(token: string, user: User): Promise<void> {
await SecureStore.setItemAsync(TOKEN_KEY, token);
await SecureStore.setItemAsync(ACCOUNT_KEY, JSON.stringify(user));
}
export async function clearStoredSession(): Promise<void> {
try {
await SecureStore.deleteItemAsync(TOKEN_KEY);
await SecureStore.deleteItemAsync(ACCOUNT_KEY);
} catch {
// clés absentes → rien à supprimer
}
}
-255
View File
@@ -1,255 +0,0 @@
import { test, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { api, setAuthToken, setUnauthorizedHandler, ApiError } from '../api/client';
import type { ApiData } from '../api/types';
function jsonResponse(status: number, body: unknown, headers?: HeadersInit): Response {
return new Response(typeof body === 'string' ? body : JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json', ...headers },
});
}
function ok(data: unknown, meta?: unknown): Response {
return jsonResponse(200, { data, ...(meta !== undefined ? { meta } : {}) });
}
let calls: { url: string; init: RequestInit }[] = [];
const originalFetch = globalThis.fetch;
function stubFetch(handler: (url: string, init: RequestInit) => Promise<Response> | Response): void {
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
calls.push({ url: String(input), init: init ?? {} });
return handler(String(input), init ?? {});
};
}
// URL absolue → chemin + query seulement (le prefix est EXPO_PUBLIC_API_BASE_URL)
function pathOf(url: string): string {
const u = new URL(url);
return u.pathname + u.search;
}
beforeEach(() => {
calls = [];
setAuthToken(null);
setUnauthorizedHandler(null);
});
afterEach(() => {
globalThis.fetch = originalFetch;
setUnauthorizedHandler(null);
});
test('nominal : GET /health', async () => {
stubFetch(() => ok({ status: 'healthy' }));
const res = await api.health();
assert.equal(pathOf(calls[0].url), '/api/v1/health');
assert.equal(calls[0].init.method ?? 'GET', 'GET');
assert.deepEqual(res.data, { status: 'healthy' });
});
test('nominal : POST /devices (register) — plus aucun token, device-only', async () => {
stubFetch(() => ok({ deviceId: 'aaaa' }));
const res = await api.registerDevice('aaaa');
assert.equal(pathOf(calls[0].url), '/api/v1/devices');
assert.equal(calls[0].init.method, 'POST');
assert.deepEqual(JSON.parse(String(calls[0].init.body)), { deviceId: 'aaaa' });
assert.deepEqual(res.data, { deviceId: 'aaaa' });
assert.ok(!('token' in res.data), 'POST /devices ne doit plus émettre de token');
});
test('nominal : POST /auth/login (seule porte de token)', async () => {
stubFetch(() =>
ok({
token: 'v4.local.xyz',
expires_at: 1700000000000,
user: { id: 'u-1', username: 'alice', is_admin: false },
}),
);
const res = await api.login('alice', 'secret-pass', 'dev-1');
assert.equal(pathOf(calls[0].url), '/api/v1/auth/login');
assert.equal(calls[0].init.method, 'POST');
assert.deepEqual(JSON.parse(String(calls[0].init.body)), {
username: 'alice',
password: 'secret-pass',
device_id: 'dev-1',
});
assert.equal(res.data.user.id, 'u-1');
assert.equal(res.data.user.is_admin, false);
});
test('auth : 401 de login ne déclenche PAS le handler de purge de session', async () => {
let purged = 0;
setUnauthorizedHandler(() => {
purged++;
});
stubFetch(() => jsonResponse(401, { error: { code: 'UNAUTHORIZED', message: 'invalid credentials' } }));
await assert.rejects(() => api.login('bob', 'wrong', 'dev-1'), (err: unknown) => (err as ApiError).code === 'UNAUTHORIZED');
assert.equal(purged, 0, 'mauvais identifiants ≠ session à purger');
});
test('auth : 401 dun endpoint protégé déclenche le handler de purge', async () => {
let purged = 0;
setUnauthorizedHandler(() => {
purged++;
});
setAuthToken('tok-expired');
stubFetch(() => jsonResponse(401, { error: { code: 'UNAUTHORIZED', message: 'token invalid' } }));
await assert.rejects(() => api.listFiles(), (err: unknown) => (err as ApiError).code === 'UNAUTHORIZED');
assert.equal(purged, 1, 'token révoqué → purge de session');
setUnauthorizedHandler(null);
});
test('nominal : PATCH /users/me/password', async () => {
stubFetch(() => ok({ id: 'u-1' }));
await api.changePassword('old-pass', 'new-pass-8chars');
assert.equal(pathOf(calls[0].url), '/api/v1/users/me/password');
assert.equal(calls[0].init.method, 'PATCH');
assert.deepEqual(JSON.parse(String(calls[0].init.body)), {
current_password: 'old-pass',
new_password: 'new-pass-8chars',
});
});
test('nominal : GET /users/resolve?username= (résolution exacte)', async () => {
stubFetch(() => ok({ id: 'u-9', username: 'bob' }));
const res = await api.resolveUser('BOB');
assert.equal(pathOf(calls[0].url), '/api/v1/users/resolve?username=BOB');
assert.equal(res.data.username, 'bob');
});
test('nominal : GET /files — query filtrée (undefined/null ignorés)', async () => {
stubFetch(() => ok([], { page: 1, pageSize: 50, total: 0 }));
const res = await api.listFiles({ folderId: 'abc', page: 2, pageSize: 50, sort: undefined });
assert.equal(pathOf(calls[0].url), '/api/v1/files?folderId=abc&page=2&pageSize=50');
assert.equal(calls[0].init.method ?? 'GET', 'GET');
assert.deepEqual(res.data, []);
assert.equal(res.meta?.total, 0);
});
test('nominal : GET/DELETE /files/:id — id encodé', async () => {
stubFetch(() => ok({ id: 'a%2Fb' }));
await api.getFile('a/b');
await api.deleteFile('a/b');
assert.equal(pathOf(calls[0].url), '/api/v1/files/a%2Fb');
assert.equal(pathOf(calls[1].url), '/api/v1/files/a%2Fb');
assert.equal(calls[1].init.method, 'DELETE');
});
test('nominal : GET /files/search?q=', async () => {
stubFetch(() => ok([]));
await api.searchFiles('rapport', { page: 1 });
assert.equal(pathOf(calls[0].url), '/api/v1/files/search?q=rapport&page=1');
});
test('nominal : GET /files/folders', async () => {
stubFetch(() => ok([{ id: 'f', name: 'Docs' }]));
const res = await api.listFolders();
assert.equal(pathOf(calls[0].url), '/api/v1/files/folders');
assert.equal(res.data[0].name, 'Docs');
});
test('nominal : POST /files/upload — multipart FormData, pas de Content-Type manuel', async () => {
stubFetch(() => ok({ id: 'u', name: 'a.txt', size: 3 }));
await api.uploadFile({ uri: 'file:///a.txt', name: 'a.txt', mimeType: 'text/plain' }, 'folder1');
assert.equal(pathOf(calls[0].url), '/api/v1/files/upload');
assert.equal(calls[0].init.method, 'POST');
const form = calls[0].init.body as FormData;
assert.ok(form instanceof FormData);
// RN transforme le pseudo-objet {uri,name,type} en part de fichier ; sous
// Node la célébration est stringifiée — on vérifie juste la présence.
assert.notEqual(form.get('file'), null, 'la part "file" doit être présente');
assert.equal(form.get('folderId'), 'folder1');
assert.equal(calls[0].init.headers, undefined, 'le client ne doit jamais fixer Content-Type');
});
test('nominal : OCR create + get', async () => {
stubFetch(() => ok({ id: 'j', status: 'queued' }));
const created = await api.createOcrJob('file-1');
assert.equal(pathOf(calls[0].url), '/api/v1/ocr/jobs');
assert.equal(calls[0].init.method, 'POST');
assert.deepEqual(JSON.parse(String(calls[0].init.body)), { fileId: 'file-1' });
await api.getOcrJob('j');
assert.equal(pathOf(calls[1].url), '/api/v1/ocr/jobs/j');
assert.equal(created.data.status, 'queued');
});
test('enveloppe : { data, meta } parsée intégralement', async () => {
stubFetch(() => ok({ id: 'x', name: 'n' }, { page: 1, pageSize: 10, total: 3 }));
const res: ApiData<{ id: string }> = await api.getFile('x');
assert.deepEqual(res, { data: { id: 'x', name: 'n' }, meta: { page: 1, pageSize: 10, total: 3 } });
});
test('hostile : 200 + HTML (proxy) → INVALID_RESPONSE', async () => {
stubFetch(() => new Response('<html>Bad Gateway</html>', { status: 200, headers: { 'Content-Type': 'text/html' } }));
await assert.rejects(() => api.health(), (err: unknown) => {
assert.ok(err instanceof ApiError);
assert.equal((err as ApiError).code, 'INVALID_RESPONSE');
return true;
});
});
test('hostile : 200 + corps vide → INVALID_RESPONSE', async () => {
stubFetch(() => new Response(null, { status: 200 }));
await assert.rejects(() => api.health(), (err: unknown) => (err as ApiError).code === 'INVALID_RESPONSE');
});
test('hostile : 200 + JSON sans clé data → INVALID_RESPONSE', async () => {
stubFetch(() => jsonResponse(200, { foo: 1 }));
await assert.rejects(() => api.health(), (err: unknown) => (err as ApiError).code === 'INVALID_RESPONSE');
});
test('hostile : 200 + enveloppe {error} → INVALID_RESPONSE', async () => {
stubFetch(() => jsonResponse(200, { error: { code: 'X', message: 'm' } }));
await assert.rejects(() => api.health(), (err: unknown) => (err as ApiError).code === 'INVALID_RESPONSE');
});
test('liste brute : 200 + [] → data tolérée', async () => {
stubFetch(() => jsonResponse(200, []));
const res = await api.listFolders();
assert.deepEqual(res, { data: [] });
});
test('erreur enveloppée : 400 {error.code} → code transmis', async () => {
stubFetch(() => jsonResponse(400, { error: { code: 'INVALID_DEVICE_ID', message: 'bad' } }));
await assert.rejects(() => api.registerDevice('zz'), (err: unknown) => {
const e = err as ApiError;
return e.code === 'INVALID_DEVICE_ID' && e.message === 'bad';
});
});
test('erreur nue : 502 HTML → HTTP_502', async () => {
stubFetch(() => new Response('<html>502</html>', { status: 502, headers: { 'Content-Type': 'text/html' } }));
await assert.rejects(() => api.health(), (err: unknown) => (err as ApiError).code === 'HTTP_502');
});
test('réseau : fetch rejette (dont timeout/AbortError) → NETWORK_ERROR', async () => {
stubFetch(() => {
throw new DOMException('The operation was aborted.', 'AbortError');
});
await assert.rejects(() => api.health(), (err: unknown) => (err as ApiError).code === 'NETWORK_ERROR');
});
test('auth : sans token, pas de header Authorization', async () => {
stubFetch(() => ok([]));
await api.listFiles();
assert.equal(calls[0].init.headers, undefined);
});
test('auth : token posé → Authorization: Bearer', async () => {
setAuthToken('tok-123');
stubFetch(() => ok([]));
await api.listFiles();
const headers = new Headers(calls[0].init.headers);
assert.equal(headers.get('Authorization'), 'Bearer tok-123');
});
test('auth : fusion avec Content-Type existant (POST /devices + token)', async () => {
setAuthToken('tok-123');
stubFetch(() => ok({ deviceId: 'a' }));
await api.registerDevice('a');
const headers = new Headers(calls[0].init.headers);
assert.equal(headers.get('Authorization'), 'Bearer tok-123');
assert.equal(headers.get('Content-Type'), 'application/json');
});
-117
View File
@@ -1,117 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
isBrokenConnectionError,
withDatabaseRetry,
type DatabaseRetryDeps,
} from '../services/db/client';
import type { SQLiteDatabase } from 'expo-sqlite';
function brokenConnectionError(): Error {
return new Error(
"Call to function 'NativeDatabase.prepareAsync' has been rejected.\n→ Caused by: java.lang.NullPointerException: java.lang.NullPointerException",
);
}
test('isBrokenConnectionError recognizes the expo-sqlite Android NPE signature', () => {
assert.equal(isBrokenConnectionError(brokenConnectionError()), true);
assert.equal(
isBrokenConnectionError(
new Error(
"Call to function 'NativeDatabase.execAsync' has been rejected.\n→ Caused by: java.lang.NullPointerException: java.lang.NullPointerException",
),
),
true,
);
});
test('isBrokenConnectionError ignores regular and non-Error values', () => {
assert.equal(isBrokenConnectionError(new Error('disk I/O error')), false);
assert.equal(isBrokenConnectionError(null), false);
assert.equal(isBrokenConnectionError('NativeDatabase.prepareAsync'), false);
});
test('withDatabaseRetry reconnects once on a dead connection and retries', async () => {
let current: SQLiteDatabase = {
runAsync: async () => {
throw brokenConnectionError();
},
} as unknown as SQLiteDatabase;
let getCalls = 0;
let recoverCalls = 0;
const deps: DatabaseRetryDeps = {
get: async () => {
getCalls++;
return current;
},
recover: async () => {
recoverCalls++;
current = {
runAsync: async () => 42 as never,
} as unknown as SQLiteDatabase;
return current;
},
};
const result = await withDatabaseRetry(async (db) => db.runAsync('SELECT 1'), deps);
assert.equal(result, 42);
assert.equal(recoverCalls, 1);
assert.equal(getCalls, 1);
});
test('withDatabaseRetry recovers when the open itself rejects with the NPE signature', async () => {
let recoverCalls = 0;
const deps: DatabaseRetryDeps = {
get: async () => {
throw brokenConnectionError();
},
recover: async () => {
recoverCalls++;
return {
runAsync: async () => 42 as never,
} as unknown as SQLiteDatabase;
},
};
const result = await withDatabaseRetry(async (db) => db.runAsync('SELECT 1'), deps);
assert.equal(result, 42);
assert.equal(recoverCalls, 1);
});
test('withDatabaseRetry does not recover on a regular database error', async () => {
let recoverCalls = 0;
const deps: DatabaseRetryDeps = {
get: async () =>
({
runAsync: async () => {
throw new Error('disk I/O error');
},
}) as unknown as SQLiteDatabase,
recover: async () => {
recoverCalls++;
return {} as SQLiteDatabase;
},
};
await assert.rejects(() => withDatabaseRetry(async (db) => db.runAsync('SELECT 1'), deps), {
message: 'disk I/O error',
});
assert.equal(recoverCalls, 0);
});
test('withDatabaseRetry converges on a broken retry (recovery does not loop forever)', async () => {
const deps: DatabaseRetryDeps = {
get: async () => ({} as SQLiteDatabase),
recover: async () => ({} as SQLiteDatabase),
};
await assert.rejects(
() =>
withDatabaseRetry(async () => {
throw brokenConnectionError();
}, deps),
(error: unknown) =>
error instanceof Error && isBrokenConnectionError(error),
);
});
-133
View File
@@ -1,133 +0,0 @@
// Smoke test live contre un backend démarré (POSTGRES + serveur Go) :
// docker compose up postgres -d (racine repo)
// cd backend && go run cmd/server/main.go
// cd mobile && npm run test:e2e
//
// NON agrégé dans `npm test` (il exige un backend fonctionnel). Si le serveur
// est injoignable, les tests sont marqués skipped, pas échoués.
import { test, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import Database from 'better-sqlite3';
import { MIGRATIONS, type MigrationDb } from '../services/db/migrations';
import type { DbSession } from '../services/db/session';
import { __setDbForTests } from '../services/db/session';
import { api, setAuthToken } from '../api/client';
import {
clearActiveUserId,
enqueuePendingOperation,
getPendingOperations,
setActiveUserId,
} from '../services/db';
import { pushPendingOps, refreshPermissions, resetPermissionCachedAtForTests } from '../features/syncOutbox';
const BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL ?? 'http://localhost:8080/api/v1';
const ADMIN_USERNAME = process.env.E2E_ADMIN_USERNAME ?? 'admin';
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'admin';
type Harness = MigrationDb & DbSession;
let h: Harness;
function createHarness(): Harness {
const sqlite = new Database(':memory:');
const harness: Harness = {
execAsync: async (sql: string) => {
sqlite.exec(sql);
},
runAsync: async (sql: string, ...params: unknown[]) => {
sqlite.prepare(sql).run(...params);
},
getFirstAsync: async (sql: string, ...params: unknown[]) =>
(sqlite.prepare(sql).get(...params) ?? null) as never,
getAllAsync: async (sql: string, ...params: unknown[]) =>
sqlite.prepare(sql).all(...params) as never,
withExclusiveTransactionAsync: async (task: (txn: Harness) => Promise<void>) => {
sqlite.exec('BEGIN');
try {
await task(harness);
sqlite.exec('COMMIT');
} catch (error) {
sqlite.exec('ROLLBACK');
throw error;
}
},
};
return harness;
}
let deviceId: string;
function newDeviceId(): string {
const bytes = new Uint8Array(16);
for (let i = 0; i < 16; i++) {
bytes[i] = Math.floor(Math.random() * 256);
}
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
}
function isBackendUp(): boolean {
return !!process.env.CI_E2E_REQUIRE_BACKEND;
}
beforeEach(async () => {
h = createHarness();
for (const migration of MIGRATIONS) {
await migration.up(h);
}
__setDbForTests(h);
resetPermissionCachedAtForTests();
});
afterEach(() => {
clearActiveUserId();
__setDbForTests(null);
setAuthToken(null);
});
test('bout en bout : register + login admin → push outbox → snapshot → relecture serveur', async (t) => {
try {
await api.health();
} catch (error) {
if (!isBackendUp()) {
t.skip(`backend injoignable sur ${BASE_URL} — lancer serveur + postgres`);
return;
}
throw error;
}
// Enregistrement du device (idempotent) puis login = SEULE porte de token.
deviceId = newDeviceId();
await api.registerDevice(deviceId);
const login = await api.login(ADMIN_USERNAME, ADMIN_PASSWORD, deviceId);
setAuthToken(login.data.token);
await setActiveUserId(login.data.user.id);
assert.equal(login.data.user.is_admin, true, 'ladmin E2E doit exister (ADMIN_*)');
// 1. Enqueue un CREATE_RESOURCE puis push (scopé au compte connecté)
const name = `e2e-${deviceId.slice(0, 8)}`;
const resourceId = newDeviceId();
const opId = await enqueuePendingOperation({
resourceId,
resourceType: 'folder',
refType: 'resource',
refId: null,
operation: 'create_resource',
payload: { name },
});
const push = await pushPendingOps();
assert.deepEqual(push, { pushed: 1, retried: 0 });
const local = await getPendingOperations();
const done = local.find((op) => op.id === opId);
assert.equal(done?.status, 'completed');
assert.equal(done?.attempts, 0);
// 2. Relecture côté serveur : le dossier racine créé est visible
const folders = await api.listFolders();
assert.ok(folders.data.some((f) => f.name === name), 'dossier absent côté serveur');
// 3. Snapshot des permissions → persisté localement
const snapshotCount = await refreshPermissions();
assert.ok(snapshotCount < 10_000, `snapshot trop volumineux (${snapshotCount})`);
});
-293
View File
@@ -1,293 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import Database from 'better-sqlite3';
import { MIGRATIONS, migrateDatabase } from '../services/db/migrations';
import { DATABASE_VERSION, DEVICE_USER_ID_KEY } from '../services/db/schema';
type Harness = {
execAsync(sql: string): Promise<void>;
runAsync(sql: string, ...params: unknown[]): Promise<void>;
getFirstAsync<T>(sql: string, ...params: unknown[]): Promise<T | null>;
getAllAsync<T>(sql: string, ...params: unknown[]): Promise<T[]>;
withExclusiveTransactionAsync<T>(task: (txn: Harness) => Promise<T>): Promise<T>;
raw: Database.Database;
};
function createHarness(): Harness {
const db = new Database(':memory:');
const self: Harness = {
execAsync: async (sql) => {
db.exec(sql);
},
runAsync: async (sql, ...params) => {
db.prepare(sql).run(...params);
},
getFirstAsync: async (sql, ...params) =>
(db.prepare(sql).get(...params) ?? null) as never,
getAllAsync: async (sql, ...params) => db.prepare(sql).all(...params) as never,
withExclusiveTransactionAsync: async (task) => {
db.exec('BEGIN');
try {
const result = await task(self);
db.exec('COMMIT');
return result;
} catch (err) {
db.exec('ROLLBACK');
throw err;
}
},
raw: db,
};
return self;
}
async function userVersion(h: Harness): Promise<number> {
const row = await h.getFirstAsync<{ user_version: number }>('PRAGMA user_version');
return row?.user_version ?? 0;
}
async function columnNames(h: Harness, table: string): Promise<string[]> {
const rows = await h.getAllAsync<{ name: string }>(`PRAGMA table_info(${table})`);
return rows.map((r) => r.name);
}
async function foreignKeyViolations(h: Harness): Promise<unknown[]> {
return h.getAllAsync('PRAGMA foreign_key_check');
}
const ROOT_URI = 'content://com.android.providers.documents/tree/primary%3ADocuments';
const SUB_URI = 'content://com.android.providers.documents/tree/primary%3ADocuments%2Fphotos%20encod%2520ed';
const EMPTY_URI = 'content://com.android.providers.documents/document/empty';
const MUSIC_URI = 'content://com.android.providers.documents/tree/primary%3AMusic';
const NOTE_URI = 'content://com.android.providers.documents/document/primary%20note.txt';
const PIC_URI = 'content://com.android.providers.documents/document/primary%3Aphotos%2Fpic%20.jpg';
const SONG_URI = 'content://com.android.providers.documents/document/primary%3ASong%20encod%2520.mp3';
async function seedLegacyTree(h: Harness): Promise<void> {
await h.execAsync(`INSERT INTO folders (uri, name, "exists", sync_status, added_at, parent_uri)
VALUES ('${ROOT_URI}', 'Docs', 1, 'local', 1000, NULL);`);
await h.execAsync(`INSERT INTO folders (uri, name, "exists", sync_status, added_at, parent_uri)
VALUES ('${SUB_URI}', 'Photos 2024', 1, 'local', 2000, '${ROOT_URI}');`);
await h.execAsync(`INSERT INTO folders (uri, name, "exists", sync_status, added_at, parent_uri)
VALUES ('${EMPTY_URI}', 'Empty', 1, 'cloud', 3000, NULL);`);
await h.execAsync(`INSERT INTO folders (uri, name, "exists", sync_status, added_at, parent_uri)
VALUES ('${MUSIC_URI}', 'Music', 0, 'local-cloud', 4000, NULL);`);
await h.execAsync(`INSERT INTO files (uri, name, folder_uri, extension, size, "type", "exists", last_modified, sync_status, added_at)
VALUES ('${NOTE_URI}', 'note.txt', '${ROOT_URI}', 'txt', 10, 'text/plain', 1, 1000, 'local', 1000);`);
await h.execAsync(`INSERT INTO files (uri, name, folder_uri, extension, size, "type", "exists", last_modified, sync_status, added_at)
VALUES ('${PIC_URI}', 'pic.jpg', '${SUB_URI}', 'jpg', 20, 'image/jpeg', 1, 2000, 'local-cloud', 2000);`);
await h.execAsync(`INSERT INTO files (uri, name, folder_uri, extension, size, "type", "exists", last_modified, sync_status, added_at)
VALUES ('${SONG_URI}', 'song.mp3', '${MUSIC_URI}', 'mp3', 30, 'audio/mpeg', 0, 3000, 'cloud', 3000);`);
}
test('fresh migrate v0 → v5 creates full schema and seeds device id', async () => {
const h = createHarness();
await migrateDatabase(h);
assert.equal(await userVersion(h), DATABASE_VERSION, `user_version should be ${DATABASE_VERSION}`);
const folderCols = await columnNames(h, 'folders');
for (const col of ['resource_id', 'uri', 'parent_resource_id', 'owner_id', 'updated_at']) {
assert.ok(folderCols.includes(col), `folders should have column ${col}`);
}
assert.ok(!folderCols.includes('parent_uri'), 'parent_uri must be replaced by parent_resource_id');
const fileCols = await columnNames(h, 'files');
for (const col of ['resource_id', 'folder_resource_id', 'owner_id', 'updated_at']) {
assert.ok(fileCols.includes(col), `files should have column ${col}`);
}
for (const table of [
'resource_permissions',
'shares',
'recipients',
'share_links',
'pending_operations',
]) {
const names = await columnNames(h, table);
assert.ok(names.length > 0, `table ${table} must exist`);
}
const permCols = await columnNames(h, 'resource_permissions');
assert.ok(permCols.includes('user_id'), 'resource_permissions must have user_id (v5)');
const opCols = await columnNames(h, 'pending_operations');
assert.ok(opCols.includes('user_id'), 'pending_operations must have user_id (v5)');
const device = await h.getFirstAsync<{ value: string }>(
'SELECT "value" FROM user_preferences WHERE "key" = ?',
DEVICE_USER_ID_KEY,
);
assert.ok(device && /^[0-9a-f]{32}$/.test(device.value), 'device_user_id seeded as 32-hex');
assert.deepEqual(await foreignKeyViolations(h), [], 'no orphaned FKs after fresh migrate');
});
test('v5 : UNIQUE de resource_permissions scopé par (user_id, resource_id, resource_type)', async () => {
const h = createHarness();
await migrateDatabase(h);
const row = await h.getFirstAsync<{ sql: string }>(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND tbl_name = 'resource_permissions'`,
);
assert.ok(row, 'table sql present');
assert.match(row.sql, /UNIQUE\s*\(user_id,\s*resource_id,\s*resource_type\)/);
// Deux comptes peuvent partager la même ressource sans collision.
await h.runAsync(
`INSERT INTO resource_permissions (user_id, resource_id, resource_type, effective_access, inherit, owner_id, cached_at, updated_at)
VALUES ('u1', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'folder', 'owner', 1, NULL, 1, 1)`,
);
await h.runAsync(
`INSERT INTO resource_permissions (user_id, resource_id, resource_type, effective_access, inherit, owner_id, cached_at, updated_at)
VALUES ('u2', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'folder', 'viewer', 0, NULL, 1, 1)`,
);
await assert.rejects(
() =>
h.runAsync(
`INSERT INTO resource_permissions (user_id, resource_id, resource_type, effective_access, inherit, owner_id, cached_at, updated_at)
VALUES ('u1', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'folder', 'editor', 1, NULL, 1, 1)`,
),
/UNIQUE constraint failed/,
);
});
test('user_version is transactional (rollback restores previous version)', async () => {
const h = createHarness();
await migrateDatabase(h);
const before = await userVersion(h);
h.raw.exec('BEGIN');
h.raw.exec('PRAGMA user_version = 999');
h.raw.exec('ROLLBACK');
assert.equal(await userVersion(h), before, 'user_version must be rolled back');
});
test('realistic v2 → v4 tree is preserved with correct reparenting', async () => {
const h = createHarness();
await migrateDatabase(h, 2);
await seedLegacyTree(h);
await migrateDatabase(h);
assert.equal(await userVersion(h), DATABASE_VERSION);
const folders = await h.getAllAsync<{
resource_id: string;
uri: string | null;
name: string;
parent_resource_id: string | null;
owner_id: string;
added_at: number;
updated_at: number;
}>('SELECT resource_id, uri, name, parent_resource_id, owner_id, added_at, updated_at FROM folders');
const files = await h.getAllAsync<{
resource_id: string;
uri: string | null;
name: string;
folder_resource_id: string;
owner_id: string;
added_at: number;
updated_at: number;
}>('SELECT resource_id, uri, name, folder_resource_id, owner_id, added_at, updated_at FROM files');
assert.equal(folders.length, 4, 'folders count preserved');
assert.equal(files.length, 3, 'files count preserved');
for (const f of folders) {
assert.match(f.resource_id, /^[0-9a-f]{32}$/, 'resource_id must be 32-hex once');
assert.ok(f.owner_id, 'owner_id must be set (never NULL implicit)');
assert.equal(f.updated_at, f.added_at, 'backfilled updated_at = added_at');
}
for (const f of files) {
assert.match(f.resource_id, /^[0-9a-f]{32}$/, 'resource_id must be 32-hex');
assert.ok(f.owner_id, 'owner_id must be set');
}
const resourceIds = [...folders.map((f) => f.resource_id), ...files.map((f) => f.resource_id)];
assert.equal(new Set(resourceIds).size, resourceIds.length, 'all resource ids unique in the same table');
const device = await h.getFirstAsync<{ value: string }>(
'SELECT "value" FROM user_preferences WHERE "key" = ?',
DEVICE_USER_ID_KEY,
);
for (const f of [...folders, ...files]) {
assert.equal(f.owner_id, device!.value, `owner_id must be device_user_id, got ${f.owner_id}`);
}
const byUri = new Map(folders.map((f) => [f.uri, f]));
const root = byUri.get(ROOT_URI);
const sub = byUri.get(SUB_URI);
assert.ok(root && sub, 'local uris preserved on local rows');
assert.equal(sub.parent_resource_id, root.resource_id, 'child folder reparented to root resource_id');
const byName = new Map(folders.map((f) => [f.name, f]));
const empty = byName.get('Empty');
assert.equal(empty!.parent_resource_id, null, 'root folder stays root');
const note = files.find((f) => f.name === 'note.txt');
const pic = files.find((f) => f.name === 'pic.jpg');
const song = files.find((f) => f.name === 'song.mp3');
assert.equal(note!.folder_resource_id, root.resource_id, 'file linked to root folder');
assert.equal(pic!.folder_resource_id, sub.resource_id, 'file linked to nested folder');
assert.equal(song!.folder_resource_id, byName.get('Music')!.resource_id, 'cloud file linked to cloud folder');
assert.deepEqual(await foreignKeyViolations(h), [], 'no orphaned FKs after v2→v4');
});
test('cascade delete works through the rebuilt self-FK', async () => {
const h = createHarness();
await migrateDatabase(h, 2);
await seedLegacyTree(h);
await migrateDatabase(h);
const folders = await h.getAllAsync<{ resource_id: string; name: string; parent_resource_id: string | null }>(
'SELECT resource_id, name, parent_resource_id FROM folders',
);
const root = folders.find((f) => f.name === 'Docs')!;
await h.runAsync('DELETE FROM folders WHERE resource_id = ?', root.resource_id);
const names = await h.getAllAsync<{ name: string }>('SELECT name FROM folders');
assert.equal(names.length, 2, 'root and its subtree removed (Docs, Photos 2024)');
const files = await h.getAllAsync<{ name: string }>('SELECT name FROM files');
assert.equal(files.length, 1, 'only Music file remains (note + pic cascaded)');
assert.deepEqual(await foreignKeyViolations(h), [], 'no orphans after cascade');
});
test('v4 crash is atomic: rollback keeps v3 schema and data, rerun succeeds', async () => {
const h = createHarness();
await migrateDatabase(h, 3);
await seedLegacyTree(h);
const originalExec = h.execAsync.bind(h);
h.execAsync = async (sql) => {
if (sql.startsWith('DROP TABLE files;')) {
throw new Error('simulated crash during rebuild');
}
return originalExec(sql);
};
const v4 = MIGRATIONS.find((m) => m.version === 4);
assert.ok(v4, 'v4 migration exists');
await assert.rejects(() => v4.up(h), /simulated crash/);
assert.equal(await userVersion(h), 3, 'user_version unchanged after failed rebuild');
assert.ok((await columnNames(h, 'folders')).includes('parent_uri'), 'old folders schema restored');
const folders = await h.getAllAsync<{ name: string }>('SELECT name FROM folders');
assert.equal(folders.length, 4, 'data intact after rollback');
h.execAsync = originalExec;
await migrateDatabase(h);
assert.equal(await userVersion(h), DATABASE_VERSION, 'rerun completes to newest version');
assert.ok((await columnNames(h, 'folders')).includes('resource_id'));
const files = await h.getAllAsync<{ name: string }>('SELECT name FROM files');
assert.equal(files.length, 3, 'files preserved after rerun');
assert.deepEqual(await foreignKeyViolations(h), [], 'no orphans after rerun');
});
test('concurrent migrateDatabase calls are single-flight', async () => {
const h = createHarness();
await Promise.all([migrateDatabase(h), migrateDatabase(h)]);
assert.equal(await userVersion(h), DATABASE_VERSION);
});
-442
View File
@@ -1,442 +0,0 @@
import { test, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import Database from 'better-sqlite3';
import { MIGRATIONS, type MigrationDb } from '../services/db/migrations';
import type { DbSession } from '../services/db/session';
import { __setDbForTests } from '../services/db/session';
import {
saveFolder,
getFolders,
getFolder,
removeFolder,
saveFile,
getFiles,
canAccess,
saveResourcePermission,
saveShare,
getShares,
createShareLink,
getShareLinks,
incrementLinkDownloads,
enqueuePendingOperation,
getPendingOperations,
getNextQueuedOperation,
markPendingOperation,
getDeviceUserId,
DEVICE_USER_ID_KEY,
PERMISSION_TTL_MS,
} from '../services/db';
import type { FileEntry } from '../services/safDirectory.types';
import { MAX_PENDING_ATTEMPTS } from '../services/db/repositories/pendingOps';
type Harness = MigrationDb & DbSession;
let h: Harness;
function createHarness(): Harness {
const sqlite = new Database(':memory:');
const harness: Harness = {
execAsync: async (sql: string) => {
sqlite.exec(sql);
},
runAsync: async (sql: string, ...params: unknown[]) => {
sqlite.prepare(sql).run(...params);
},
getFirstAsync: async (sql: string, ...params: unknown[]) =>
(sqlite.prepare(sql).get(...params) ?? null) as never,
getAllAsync: async (sql: string, ...params: unknown[]) =>
sqlite.prepare(sql).all(...params) as never,
withExclusiveTransactionAsync: async (task: (txn: Harness) => Promise<void>) => {
sqlite.exec('BEGIN');
try {
await task(harness);
sqlite.exec('COMMIT');
} catch (error) {
sqlite.exec('ROLLBACK');
throw error;
}
},
};
return harness;
}
beforeEach(async () => {
h = createHarness();
for (const migration of MIGRATIONS) {
await migration.up(h);
}
__setDbForTests(h);
});
afterEach(() => {
__setDbForTests(null);
});
function fileEntry(uri: string, overrides: Partial<FileEntry> = {}): FileEntry {
return {
uri,
name: uri.split('/').pop() ?? uri,
isDirectory: false,
extension: uri.split('.').pop() ?? '',
exists: true,
size: 10,
type: 'application/octet-stream',
lastModified: 1,
...overrides,
};
}
async function setOwner(
table: 'folders' | 'files',
resourceId: string,
ownerId: string,
): Promise<void> {
await h.runAsync(`UPDATE ${table} SET owner_id = ? WHERE resource_id = ?`, ownerId, resourceId);
}
test('folder upsert is idempotent by uri (same resource_id, single row)', async () => {
const a = await saveFolder({ uri: 'content://a', name: 'A' });
const b = await saveFolder({ uri: 'content://a', name: 'A renamed' });
assert.equal(a.resource_id, b.resource_id);
assert.equal(b.name, 'A renamed');
assert.equal(b.uri, 'content://a');
assert.equal((await getFolders()).length, 1);
});
test('distinct uris produce distinct rows', async () => {
await saveFolder({ uri: 'content://a', name: 'A' });
await saveFolder({ uri: 'content://b', name: 'B' });
assert.equal((await getFolders()).length, 2);
});
test('cloud-only folder gains a uri without duplicating (reconciled by resource_id)', async () => {
const cloud = await saveFolder({ uri: null, name: 'Cloud', resource_id: 'abc123' });
assert.equal(cloud.uri, null);
const wired = await saveFolder({ uri: 'content://cloud', name: 'Cloud', resource_id: 'abc123' });
assert.equal(wired.resource_id, 'abc123');
assert.equal(wired.uri, 'content://cloud');
assert.equal((await getFolders()).length, 1);
});
test('inserting a folder with a nonexistent parent throws a FK error (no silent orphan)', async () => {
await assert.rejects(
() => saveFolder({ uri: 'content://child', name: 'C' }, { parentResourceId: 'does-not-exist' }),
/FOREIGN KEY/,
);
});
test('inserting a file before its parent folder throws a FK error', async () => {
await assert.rejects(
() => saveFile(fileEntry('content://orphan/f.pdf'), 'missing-folder'),
/FOREIGN KEY/,
);
});
test('re-saving a folder without a parent keeps the existing parent', async () => {
const root = await saveFolder({ uri: 'content://r', name: 'R' });
const child = await saveFolder({ uri: 'content://r/c', name: 'C' }, { parentResourceId: root.resource_id });
const reSaved = await saveFolder({ uri: 'content://r/c', name: 'C2' });
assert.equal(reSaved.parent_resource_id, root.resource_id);
});
test('a full SAF walk repeated twice is a no-op (BFS order, uri reconciliation)', async () => {
const root = await saveFolder({ uri: 'content://root', name: 'Root' });
const sub = await saveFolder({ uri: 'content://root/sub', name: 'Sub' }, { parentResourceId: root.resource_id });
await saveFile(fileEntry('content://root/sub/f1.pdf'), sub.resource_id);
const root2 = await saveFolder({ uri: 'content://root', name: 'Root' });
const sub2 = await saveFolder({ uri: 'content://root/sub', name: 'Sub' }, { parentResourceId: root2.resource_id });
await saveFile(fileEntry('content://root/sub/f1.pdf'), sub2.resource_id);
assert.equal(root.resource_id, root2.resource_id);
assert.equal(sub.resource_id, sub2.resource_id);
assert.equal(sub2.parent_resource_id, root2.resource_id);
assert.equal((await getFolders()).length, 2);
assert.equal((await getFiles()).length, 1);
const stored = await getFiles(sub2.resource_id);
assert.equal(stored.length, 1);
});
test('removing a root folder cascades to its subtree and files', async () => {
const root = await saveFolder({ uri: 'content://root', name: 'Root' });
const sub = await saveFolder({ uri: 'content://root/sub', name: 'Sub' }, { parentResourceId: root.resource_id });
await saveFile(fileEntry('content://root/sub/f1.pdf'), sub.resource_id);
await removeFolder(root.resource_id);
assert.equal((await getFolders()).length, 0);
assert.equal((await getFiles()).length, 0);
});
test('canAccess: nothing granted => denied, owner fallback grants owner', async () => {
const root = await saveFolder({ uri: 'content://r', name: 'R' });
const sub = await saveFolder({ uri: 'content://r/s', name: 'S' }, { parentResourceId: root.resource_id });
const f = await saveFile(fileEntry('content://r/s/f.pdf'), sub.resource_id);
const device = await getDeviceUserId();
await setOwner('folders', root.resource_id, 'other');
await setOwner('folders', sub.resource_id, 'other');
await setOwner('files', f.resource_id, 'other');
assert.equal((await canAccess(f.resource_id, 'file', 'viewer')).allowed, false);
assert.equal((await canAccess(sub.resource_id, 'folder', 'viewer')).allowed, false);
await setOwner('folders', sub.resource_id, device);
assert.equal((await canAccess(f.resource_id, 'file', 'owner')).allowed, true);
assert.equal((await canAccess(f.resource_id, 'file', 'viewer')).source, 'owner');
});
test('canAccess: granted permission on an ancestor inherits through deep folders', async () => {
const ids: string[] = [];
let parentResourceId: string | null = null;
for (let depth = 0; depth < 5; depth++) {
const folder = await saveFolder(
{ uri: `content://chain/${depth}`, name: `n${depth}` },
{ parentResourceId },
);
ids.push(folder.resource_id);
parentResourceId = folder.resource_id;
}
const f = await saveFile(fileEntry('content://chain/f.pdf'), parentResourceId!);
const device = await getDeviceUserId();
for (const id of ids) await setOwner('folders', id, 'other');
await setOwner('files', f.resource_id, 'other');
await saveResourcePermission({
resource_id: ids[0],
resourceType: 'folder',
effectiveAccess: 'viewer',
inherit: true,
});
assert.equal((await canAccess(f.resource_id, 'file', 'viewer')).allowed, true, 'viewer inherited from root');
assert.equal((await canAccess(f.resource_id, 'file', 'editor')).allowed, false, 'viewer is not editor');
assert.equal((await canAccess(f.resource_id, 'file', 'viewer')).source, 'inherited');
});
test('canAccess: inherit=false blocks propagation but allows the node itself', async () => {
const root = await saveFolder({ uri: 'content://r', name: 'R' });
const sub = await saveFolder({ uri: 'content://r/s', name: 'S' }, { parentResourceId: root.resource_id });
const f = await saveFile(fileEntry('content://r/s/f.pdf'), sub.resource_id);
const device = await getDeviceUserId();
await setOwner('folders', root.resource_id, 'other');
await setOwner('folders', sub.resource_id, 'other');
await setOwner('files', f.resource_id, 'other');
await saveResourcePermission({
resource_id: root.resource_id,
resourceType: 'folder',
effectiveAccess: 'viewer',
inherit: false,
});
assert.equal((await canAccess(root.resource_id, 'folder', 'viewer')).allowed, true, 'root itself keeps viewer');
assert.equal((await canAccess(sub.resource_id, 'folder', 'viewer')).allowed, false, 'inheritance stopped');
assert.equal((await canAccess(f.resource_id, 'file', 'viewer')).allowed, false);
await saveResourcePermission({
resource_id: sub.resource_id,
resourceType: 'folder',
effectiveAccess: 'viewer',
inherit: true,
});
assert.equal((await canAccess(f.resource_id, 'file', 'viewer')).allowed, true, 'closer ancestor grants descend');
});
test('canAccess: an expired permission is denied and does not propagate', async () => {
const root = await saveFolder({ uri: 'content://r', name: 'R' });
const sub = await saveFolder({ uri: 'content://r/s', name: 'S' }, { parentResourceId: root.resource_id });
const device = await getDeviceUserId();
await setOwner('folders', root.resource_id, 'other');
await setOwner('folders', sub.resource_id, 'other');
await saveResourcePermission({
resource_id: root.resource_id,
resourceType: 'folder',
effectiveAccess: 'viewer',
inherit: true,
expiresAt: Date.now() - 1000,
});
assert.equal((await canAccess(root.resource_id, 'folder', 'viewer')).allowed, false);
assert.equal((await canAccess(sub.resource_id, 'folder', 'viewer')).allowed, false);
});
test('canAccess: stale cache only allows read (viewer), fresh cache allows the granted level', async () => {
const root = await saveFolder({ uri: 'content://r', name: 'R' });
const sub = await saveFolder({ uri: 'content://r/s', name: 'S' }, { parentResourceId: root.resource_id });
const device = await getDeviceUserId();
await setOwner('folders', root.resource_id, 'other');
await setOwner('folders', sub.resource_id, 'other');
await saveResourcePermission({
resource_id: root.resource_id,
resourceType: 'folder',
effectiveAccess: 'editor',
inherit: true,
});
assert.equal((await canAccess(sub.resource_id, 'folder', 'editor')).allowed, true, 'fresh editor grant');
await h.runAsync(
'UPDATE resource_permissions SET cached_at = ? WHERE resource_id = ?',
Date.now() - PERMISSION_TTL_MS - 60_000,
root.resource_id,
);
assert.equal((await canAccess(sub.resource_id, 'folder', 'viewer')).allowed, true, 'stale still allows read');
assert.equal((await canAccess(sub.resource_id, 'folder', 'editor')).allowed, false, 'stale blocks writes');
assert.equal((await canAccess(sub.resource_id, 'folder', 'viewer')).stale, true);
});
test('canAccess: a cached decision on the exact node is authoritative even if weaker', async () => {
const root = await saveFolder({ uri: 'content://r', name: 'R' });
const sub = await saveFolder({ uri: 'content://r/s', name: 'S' }, { parentResourceId: root.resource_id });
const device = await getDeviceUserId();
await setOwner('folders', root.resource_id, 'other');
await setOwner('folders', sub.resource_id, 'other');
await saveResourcePermission({
resource_id: root.resource_id,
resourceType: 'folder',
effectiveAccess: 'editor',
inherit: true,
});
await saveResourcePermission({
resource_id: sub.resource_id,
resourceType: 'folder',
effectiveAccess: 'viewer',
inherit: true,
});
assert.equal((await canAccess(sub.resource_id, 'folder', 'viewer')).allowed, true);
assert.equal((await canAccess(sub.resource_id, 'folder', 'editor')).allowed, false, 'exact viewer cache overrides inherited editor');
});
test('outbox: ops on the same resource are drained in FIFO order (id tie-break)', async () => {
const id1 = await enqueuePendingOperation({ operation: 'create_resource', resourceId: 'a' });
const id2 = await enqueuePendingOperation({ operation: 'update_metadata', resourceId: 'a' });
const id3 = await enqueuePendingOperation({ operation: 'delete_resource', resourceId: 'a' });
await h.runAsync('UPDATE pending_operations SET created_at = ?', 1000);
const ops = await getPendingOperations();
assert.deepEqual(
ops.map((o) => o.operation),
['create_resource', 'update_metadata', 'delete_resource'],
);
const first = await getNextQueuedOperation();
assert.equal(first!.id, id1);
await markPendingOperation(first!.id, 'completed');
const second = await getNextQueuedOperation();
assert.equal(second!.id, id2);
await markPendingOperation(second!.id, 'completed');
assert.equal((await getNextQueuedOperation())!.id, id3);
});
test('outbox: a failure stays pending with a backoff, then dead-letters after max attempts', async () => {
const id = await enqueuePendingOperation({ operation: 'create_resource', resourceId: 'a' });
await markPendingOperation(id, 'failed', 'boom');
let row = await h.getFirstAsync<{
status: string;
attempts: number;
next_retry_at: number | null;
}>('SELECT status, attempts, next_retry_at FROM pending_operations WHERE id = ?', id);
assert.equal(row!.status, 'pending');
assert.equal(row!.attempts, 1);
assert.ok((row!.next_retry_at ?? 0) > Date.now(), 'retry scheduled in the future');
assert.equal(await getNextQueuedOperation(), null, 'backoff respects next_retry_at');
await h.runAsync('UPDATE pending_operations SET next_retry_at = ? WHERE id = ?', 0, id);
const due = await getNextQueuedOperation();
assert.ok(due);
assert.equal(due.attempts, 1);
for (let i = 0; i < MAX_PENDING_ATTEMPTS - 1; i++) {
await markPendingOperation(id, 'failed', 'still failing');
}
row = await h.getFirstAsync<{
status: string;
attempts: number;
next_retry_at: number | null;
}>('SELECT status, attempts, next_retry_at FROM pending_operations WHERE id = ?', id);
assert.equal(row!.status, 'failed', 'dead-lettered');
assert.equal(row!.attempts, MAX_PENDING_ATTEMPTS);
assert.equal(row!.next_retry_at, null);
assert.equal(await getNextQueuedOperation(), null, 'dead-lettered ops are never re-picked');
const share = await saveShare({
resourceId: 'a',
resourceType: 'folder',
recipientType: 'user',
recipientId: 'u1',
relation: 'viewer',
});
assert.equal(share.pushStatus, 'synced');
});
test('shares and share links derive pushStatus from the outbox', async () => {
const share = await saveShare({
resourceId: 'res-1',
resourceType: 'folder',
recipientType: 'user',
recipientId: 'u1',
relation: 'editor',
});
assert.equal(share.pushStatus, 'synced');
const opId = await enqueuePendingOperation({
refType: 'share',
refId: share.id,
operation: 'share',
resourceId: 'res-1',
resourceType: 'folder',
});
assert.equal((await getShares('res-1', 'folder'))[0].pushStatus, 'pending');
await markPendingOperation(opId, 'failed', 'nope');
assert.equal(
(await getShares('res-1', 'folder'))[0].pushStatus,
'pending',
'a single failure schedules a retry, share stays pending',
);
for (let attempt = 1; attempt < MAX_PENDING_ATTEMPTS; attempt++) {
await markPendingOperation(opId, 'failed', 'still failing');
}
assert.equal((await getShares('res-1', 'folder'))[0].pushStatus, 'failed', 'dead-lettered');
await markPendingOperation(opId, 'completed');
const shares = await getShares('res-1', 'folder');
assert.equal(shares.length, 1);
assert.equal(shares[0].pushStatus, 'synced', 'no pending/failed left');
const link = await createShareLink({ resourceId: 'res-1', resourceType: 'folder' });
assert.match(link.token, /^[0-9a-f]{32}$/);
assert.equal(link.pushStatus, 'synced');
const linkOpId = await enqueuePendingOperation({
refType: 'share_link',
refId: link.id,
operation: 'create_link',
});
assert.equal((await getShareLinks('res-1', 'folder'))[0].pushStatus, 'pending');
await markPendingOperation(linkOpId, 'completed');
assert.equal((await getShareLinks('res-1', 'folder'))[0].pushStatus, 'synced');
await incrementLinkDownloads(link.id);
assert.equal((await getShareLinks('res-1', 'folder'))[0].downloadsCount, 1);
});
test('device user id is a stable lowercase 32-hex string shared by all rows', async () => {
const device = await getDeviceUserId();
assert.match(device, /^[0-9a-f]{32}$/);
const root = await saveFolder({ uri: 'content://r', name: 'R' });
const rootRow = await getFolder(root.resource_id);
assert.equal(rootRow!.owner_id, device);
const seeded = await h.getFirstAsync<{ value: string }>(
'SELECT "value" FROM user_preferences WHERE "key" = ?',
DEVICE_USER_ID_KEY,
);
assert.equal(seeded!.value, device);
});
-129
View File
@@ -1,129 +0,0 @@
import { test, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { __setSafWalkForTests, listFoldersChunked } from '../services/safWalk';
import type { DirectoryEntry, Folder } from '../services/safDirectory.types';
function dir(uri: string): Folder {
return { uri, name: uri.split('/').pop() ?? uri, isDirectory: true, exists: true };
}
function file(uri: string): DirectoryEntry {
return {
uri,
name: uri.split('/').pop() ?? uri,
isDirectory: false,
extension: '',
exists: true,
size: 1,
type: 'text/plain',
lastModified: 0,
};
}
function info(uri: string): { uri: string; name: string; exists: boolean } {
return { uri, name: uri.split('/').pop() ?? uri, exists: true };
}
const tree: Record<string, DirectoryEntry[]> = {
'/root': [dir('/root/a'), dir('/root/b'), dir('/root/c'), file('/root/f1')],
'/root/a': [dir('/root/a/x'), file('/root/a/f2')],
'/root/a/x': [],
'/root/b': [file('/root/b/f3')],
'/root/c': [],
};
const restoreFns: Array<() => void> = [];
afterEach(() => {
while (restoreFns.length) restoreFns.pop()?.();
});
function fakeSaf() {
return __setSafWalkForTests({
list: (uri) => tree[uri] ?? [],
info,
});
}
test('listFoldersChunked : parcours récursif identique à listFolders', async () => {
restoreFns.push(fakeSaf());
const folders = await listFoldersChunked('/root', { recursive: true, includeRoot: true });
assert.deepEqual(
folders.map((f) => f.uri),
['/root', '/root/a', '/root/a/x', '/root/b', '/root/c'],
);
});
test('listFoldersChunked : non-récursif ne remonte que les sous-dossiers immédiats', async () => {
restoreFns.push(fakeSaf());
const folders = await listFoldersChunked('/root');
assert.deepEqual(
folders.map((f) => f.uri),
['/root/a', '/root/b', '/root/c'],
);
});
test('listFoldersChunked : includeRoot=false ne retourne pas la racine', async () => {
restoreFns.push(fakeSaf());
const folders = await listFoldersChunked('/root', { recursive: true });
assert.deepEqual(
folders.map((f) => f.uri),
['/root/a', '/root/a/x', '/root/b', '/root/c'],
);
});
test('listFoldersChunked : cède au event loop (budget 0 → yield par dossier)', async () => {
restoreFns.push(fakeSaf());
let yields = 0;
restoreFns.push(
__setSafWalkForTests({
yield: async () => {
yields++;
},
}),
);
const folders = await listFoldersChunked('/root', { recursive: true }, 0);
assert.deepEqual(
folders.map((f) => f.uri),
['/root/a', '/root/a/x', '/root/b', '/root/c'],
);
assert.ok(yields >= 5, `expected at least one yield per folder, got ${yields}`);
});
test('listFoldersChunked : budget maximum → aucun yield, résultat inchangé', async () => {
restoreFns.push(fakeSaf());
let yields = 0;
restoreFns.push(
__setSafWalkForTests({
yield: async () => {
yields++;
},
}),
);
const folders = await listFoldersChunked('/root', { recursive: true }, Number.MAX_SAFE_INTEGER);
assert.deepEqual(
folders.map((f) => f.uri),
['/root/a', '/root/a/x', '/root/b', '/root/c'],
);
assert.equal(yields, 0);
});
test('__setSafWalkForTests : la restauration rend limplémentation dorigine', async () => {
restoreFns.push(fakeSaf());
const restore = __setSafWalkForTests({ list: () => [dir('/fake')] });
restore();
let listed = false;
restoreFns.push(
__setSafWalkForTests({
list: (uri) => {
listed = true;
return tree[uri] ?? [];
},
}),
);
await listFoldersChunked('/root', { recursive: true });
assert.equal(listed, true);
});
-276
View File
@@ -1,276 +0,0 @@
import { test, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import Database from 'better-sqlite3';
import { MIGRATIONS, type MigrationDb } from '../services/db/migrations';
import type { DbSession } from '../services/db/session';
import { __setDbForTests } from '../services/db/session';
import { __setWithTransactionForTests } from '../services/db/client';
import { __setSafWalkForTests } from '../services/safWalk';
import type { DirectoryEntry, Folder } from '../services/safDirectory.types';
import { syncRoot, syncDevice } from '../features/syncDevice';
import { saveFolder, getFolders, getFolderFolders, getFiles } from '../services/db';
type Harness = MigrationDb & DbSession;
let h: Harness;
let restoreFns: Array<() => void> = [];
function dir(uri: string): Folder {
return { uri, name: uri.split('/').pop() ?? uri, isDirectory: true, exists: true };
}
function file(uri: string): DirectoryEntry {
return {
uri,
name: uri.split('/').pop() ?? uri,
isDirectory: false,
extension: '',
exists: true,
size: 1,
type: 'text/plain',
lastModified: 0,
};
}
function info(uri: string): { uri: string; name: string; exists: boolean } {
return { uri, name: uri.split('/').pop() ?? uri, exists: true };
}
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')],
'/rootA': [dir('/rootA/sub')],
'/rootA/sub': [],
'/rootB': [file('/rootB/fB.txt')],
};
function createHarness(): Harness {
const sqlite = new Database(':memory:');
const harness: Harness = {
execAsync: async (sql: string) => {
sqlite.exec(sql);
},
runAsync: async (sql: string, ...params: unknown[]) => {
sqlite.prepare(sql).run(...params);
},
getFirstAsync: async (sql: string, ...params: unknown[]) =>
(sqlite.prepare(sql).get(...params) ?? null) as never,
getAllAsync: async (sql: string, ...params: unknown[]) =>
sqlite.prepare(sql).all(...params) as never,
withExclusiveTransactionAsync: async (task: (txn: Harness) => Promise<void>) => {
sqlite.exec('BEGIN');
try {
await task(harness);
sqlite.exec('COMMIT');
} catch (error) {
sqlite.exec('ROLLBACK');
throw error;
}
},
};
return harness;
}
function fakeSaf(): () => void {
return __setSafWalkForTests({
list: (uri) => tree[uri] ?? [],
info,
});
}
async function waitUntil(condition: () => boolean, timeoutMs = 2000): Promise<void> {
const start = Date.now();
while (!condition()) {
if (Date.now() - start >= timeoutMs) throw new Error('waitUntil: timeout');
await new Promise((resolve) => setTimeout(resolve, 5));
}
}
beforeEach(async () => {
h = createHarness();
for (const migration of MIGRATIONS) {
await migration.up(h);
}
__setDbForTests(h);
__setWithTransactionForTests(async (work) => work({} as never));
restoreFns.push(fakeSaf());
});
afterEach(() => {
__setDbForTests(null);
__setWithTransactionForTests(null);
while (restoreFns.length) restoreFns.pop()?.();
});
test('walk complet : comptes folders/files/missing, parent_resource_id cohérent, exists=1', async () => {
const root = await saveFolder({ uri: '/root', name: 'Root' });
const result = await syncRoot(root.resource_id);
assert.deepEqual(result, { rootUri: '/root', folders: 4, files: 3, missing: 0 });
const stored = await getFiles();
assert.equal(stored.length, 3);
for (const f of stored) {
assert.equal(f.exists, true);
}
const subs = await getFolderFolders(root.resource_id);
assert.equal(subs.length, 2);
const a = subs.find((f) => f.name === 'a')!;
assert.equal(a.parent_resource_id, root.resource_id);
assert.equal(a.exists, true);
const deep = await getFolderFolders(a.resource_id);
assert.equal(deep.length, 1);
assert.equal(deep[0].name, 'x');
assert.equal(deep[0].parent_resource_id, a.resource_id);
assert.equal(deep[0].exists, true);
});
test('single-flight : un second walk nest pas lancé tant que le premier est en cours', async () => {
const rootA = await saveFolder({ uri: '/rootA', name: 'RootA' });
const rootB = await saveFolder({ uri: '/rootB', name: 'RootB' });
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
let enteredYield = false;
let listingsB = 0;
restoreFns.push(
__setSafWalkForTests({
list: (uri) => {
if (uri.startsWith('/rootA') && !enteredYield) {
// Prolonge le walk de A pour dépasser le budget de 16ms et atteindre
// son premier `yield` (qui bloque sur la gate) — sinon A se termine
// trop vite et la sérialisation ne peut pas être observée.
const until = Date.now() + 20;
while (Date.now() < until) {}
}
if (uri.startsWith('/rootB')) listingsB++;
return tree[uri] ?? [];
},
yield: async () => {
enteredYield = true;
await gate;
},
info,
}),
);
const pA = syncRoot(rootA.resource_id);
await waitUntil(() => enteredYield);
const pB = syncRoot(rootB.resource_id);
await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(listingsB, 0, 'pB doit attendre la fin de pA (sérialisation des walks)');
release();
const [rA, rB] = await Promise.all([pA, pB]);
assert.equal(rA.folders, 2);
assert.equal(rB.folders, 1);
assert.ok(listingsB > 0, 'pB doit avoir walké après la libération');
});
test('toute la phase de listing SAF précède la moindre écriture DB', async () => {
const root = await saveFolder({ uri: '/root', name: 'Root' });
let txStarted = false;
__setWithTransactionForTests(async (work) => {
txStarted = true;
return work({} as never);
});
let listCount = 0;
restoreFns.push(
__setSafWalkForTests({
list: (uri) => {
assert.equal(txStarted, false, `écriture DB pendant le listing SAF (liste ${uri})`);
listCount++;
return tree[uri] ?? [];
},
info,
}),
);
const result = await syncRoot(root.resource_id);
assert.ok(listCount >= 4, `listing complet attendu, obtenu ${listCount}`);
assert.equal(result.folders, 4);
assert.equal(result.files, 3);
});
test('walk répété : idempotent, pas de doublon ni SQLITE_CONSTRAINT', async () => {
const root = await saveFolder({ uri: '/root', name: 'Root' });
await syncRoot(root.resource_id);
const foldersBefore = new Map((await getFolders()).map((f) => [f.uri, f.resource_id]));
const filesUriBefore = new Set((await getFiles()).map((f) => f.uri));
await syncRoot(root.resource_id);
const foldersAfter = await getFolders();
assert.equal(foldersAfter.length, foldersBefore.size);
for (const f of foldersAfter) {
assert.equal(foldersBefore.get(f.uri), f.resource_id, `resource_id stable pour ${f.uri}`);
}
const filesAfter = await getFiles();
assert.equal(filesAfter.length, filesUriBefore.size);
for (const f of filesAfter) {
assert.ok(filesUriBefore.has(f.uri));
assert.equal(f.exists, true);
}
});
test('réconciliation : un fichier retiré du SAF passe à exists=0 et compte en missing', async () => {
const root = await saveFolder({ uri: '/root', name: 'Root' });
await syncRoot(root.resource_id);
assert.equal((await getFiles()).length, 3);
restoreFns.push(
__setSafWalkForTests({
list: (uri) =>
uri === '/root'
? (tree['/root'] ?? []).filter((e) => e.uri !== '/root/f1.txt')
: (tree[uri] ?? []),
info,
}),
);
const result = await syncRoot(root.resource_id);
assert.equal(result.missing, 1);
const f1 = (await getFiles()).find((f) => f.uri === '/root/f1.txt')!;
assert.equal(f1.exists, false);
const remaining = (await getFiles()).filter((f) => f.exists);
assert.equal(remaining.length, 2);
});
test('syncDevice marche toutes les roots présentes', async () => {
const rootA = await saveFolder({ uri: '/rootA', name: 'RootA' });
await saveFolder({ uri: '/rootB', name: 'RootB' });
assert.equal(rootA.parent_resource_id, null);
const results = await syncDevice();
assert.equal(results.length, 2);
const byRoot = new Map(results.map((r) => [r.rootUri, r]));
assert.deepEqual(byRoot.get('/rootA'), {
rootUri: '/rootA',
folders: 2,
files: 0,
missing: 0,
});
assert.deepEqual(byRoot.get('/rootB'), {
rootUri: '/rootB',
folders: 1,
files: 1,
missing: 0,
});
});
-402
View File
@@ -1,402 +0,0 @@
import { test, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import Database from 'better-sqlite3';
import { MIGRATIONS, type MigrationDb } from '../services/db/migrations';
import type { DbSession } from '../services/db/session';
import { __setDbForTests } from '../services/db/session';
import { api, setAuthToken } from '../api/client';
import {
enqueuePendingOperation,
getActiveUserId,
getPendingOperations,
getResourcePermission,
listQueuedOperations,
markPendingOperation,
scheduleRetries,
setActiveUserId,
MAX_PENDING_ATTEMPTS,
} from '../services/db';
import {
pushPendingOps,
refreshPermissions,
resetPermissionCachedAtForTests,
} from '../features/syncOutbox';
import type { PendingOperationType } from '../services/db/types';
type Harness = MigrationDb & DbSession;
let h: Harness;
function createHarness(): Harness {
const sqlite = new Database(':memory:');
const harness: Harness = {
execAsync: async (sql: string) => {
sqlite.exec(sql);
},
runAsync: async (sql: string, ...params: unknown[]) => {
sqlite.prepare(sql).run(...params);
},
getFirstAsync: async (sql: string, ...params: unknown[]) =>
(sqlite.prepare(sql).get(...params) ?? null) as never,
getAllAsync: async (sql: string, ...params: unknown[]) =>
sqlite.prepare(sql).all(...params) as never,
withExclusiveTransactionAsync: async (task: (txn: Harness) => Promise<void>) => {
sqlite.exec('BEGIN');
try {
await task(harness);
sqlite.exec('COMMIT');
} catch (error) {
sqlite.exec('ROLLBACK');
throw error;
}
},
};
return harness;
}
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
function ok(data: unknown): Response {
return jsonResponse(200, { data });
}
let calls: { url: string; init: RequestInit }[] = [];
const originalFetch = globalThis.fetch;
function stubFetch(handler: (url: string, init: RequestInit) => Promise<Response> | Response): void {
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const initSafe = init ?? {};
calls.push({ url: String(input), init: initSafe });
return handler(String(input), initSafe);
};
}
function pathOf(url: string): string {
const u = new URL(url);
return u.pathname + u.search;
}
beforeEach(async () => {
h = createHarness();
for (const migration of MIGRATIONS) {
await migration.up(h);
}
__setDbForTests(h);
calls = [];
setAuthToken('v4.local.test-token');
resetPermissionCachedAtForTests();
stubFetch(() => ok({ applied: 0, failed: null }));
});
afterEach(() => {
__setDbForTests(null);
globalThis.fetch = originalFetch;
});
async function enqueue(op: {
operation: PendingOperationType;
resourceId?: string;
payload?: Record<string, unknown>;
}) {
return enqueuePendingOperation({
resourceId: op.resourceId ?? 'a'.repeat(32),
resourceType: 'folder',
refType: 'resource',
refId: 7,
operation: op.operation,
payload: op.payload ?? { name: 'Dossier' },
});
}
test('sans token : aucun push, aucune requête réseau', async () => {
setAuthToken(null);
await enqueue({ operation: 'create_resource' });
const result = await pushPendingOps();
assert.deepEqual(result, { pushed: 0, retried: 0 });
assert.equal(calls.length, 0);
});
test('queue vide : aucune requête réseau', async () => {
const result = await pushPendingOps();
assert.deepEqual(result, { pushed: 0, retried: 0 });
assert.equal(calls.length, 0);
});
test('succès complet : toutes les ops passent à completed, body conforme', async () => {
const id1 = await enqueue({ operation: 'create_resource', payload: { name: 'A' } });
const id2 = await enqueue({ operation: 'create_resource', payload: { name: 'B' } });
stubFetch(() => ok({ applied: 2, failed: null }));
const result = await pushPendingOps();
assert.deepEqual(result, { pushed: 2, retried: 0 });
assert.equal(pathOf(calls[0].url), '/api/v1/sync/ops');
assert.equal(calls[0].init.method, 'POST');
const body = JSON.parse(String(calls[0].init.body));
assert.equal(body.operations.length, 2);
assert.equal(body.operations[0].operation_id, id1);
assert.equal(body.operations[0].resource_type, 'folder');
assert.deepEqual(body.operations[0].payload, { name: 'A' });
assert.equal(body.operations[1].operation_id, id2);
const ops = await getPendingOperations();
const byId = new Map(ops.map((op) => [op.id, op]));
assert.equal(byId.get(id1)?.status, 'completed');
assert.equal(byId.get(id2)?.status, 'completed');
assert.equal(byId.get(id1)?.attempts, 0);
});
test('succès partiel : reprise exacte à lindex applied (les suivantes intactes)', async () => {
const id1 = await enqueue({ operation: 'create_resource' });
const id2 = await enqueue({ operation: 'create_resource' });
const id3 = await enqueue({ operation: 'create_resource' });
stubFetch(() =>
ok({
applied: 1,
failed: { operation_id: id2, code: 'NAME_CONFLICT', message: 'exists déjà' },
}),
);
const result = await pushPendingOps();
assert.deepEqual(result, { pushed: 1, retried: 0 });
const ops = await getPendingOperations();
const byId = new Map(ops.map((op) => [op.id, op]));
assert.equal(byId.get(id1)?.status, 'completed');
assert.equal(byId.get(id2)?.status, 'pending');
assert.equal(byId.get(id2)?.attempts, 1); // backoff programmé, par dead-letter
assert.ok((byId.get(id2)?.nextRetryAt ?? 0) > 0);
assert.match(byId.get(id2)?.error ?? '', /NAME_CONFLICT/);
assert.equal(byId.get(id3)?.status, 'pending');
assert.equal(byId.get(id3)?.attempts, 0); // jamais soumise → intacte
});
test('applied = 0 + échec : rien commité, pas de dead-letter prématurée', async () => {
const id1 = await enqueue({ operation: 'create_resource' });
stubFetch(() =>
ok({ applied: 0, failed: { operation_id: id1, code: 'INVALID_REQUEST', message: 'payload invalide' } }),
);
const result = await pushPendingOps();
assert.deepEqual(result, { pushed: 0, retried: 0 });
const [op] = await getPendingOperations();
assert.equal(op.status, 'pending');
assert.equal(op.attempts, 1); // < MAX_PENDING_ATTEMPTS, le cycle recommence
assert.ok((op.nextRetryAt ?? 0) > 0);
});
test('échec transitoire (fetch rejette) : retrié sans incrémenter attempts', async () => {
const id1 = await enqueue({ operation: 'create_resource' });
const id2 = await enqueue({ operation: 'create_resource' });
stubFetch(() => {
throw new Error('network unreachable');
});
const result = await pushPendingOps();
assert.deepEqual(result, { pushed: 0, retried: 2 });
const ops = await getPendingOperations();
for (const op of ops) {
assert.equal(op.status, 'pending');
assert.equal(op.attempts, 0);
assert.ok((op.nextRetryAt ?? 0) > Date.now());
}
});
test('échec transitoire 5xx : idem (HTTP_500 → retry, pas de dead-letter)', async () => {
const id1 = await enqueue({ operation: 'create_resource' });
stubFetch(() => jsonResponse(500, { error: { code: 'INTERNAL', message: 'boom' } }));
const result = await pushPendingOps();
assert.deepEqual(result, { pushed: 0, retried: 1 });
const [op] = await getPendingOperations();
assert.equal(op.status, 'pending');
assert.equal(op.attempts, 0);
});
test('refus terminal répété : dead-letter après MAX_PENDING_ATTEMPTS', async () => {
const id1 = await enqueue({ operation: 'create_resource' });
stubFetch(() =>
ok({ applied: 0, failed: { operation_id: id1, code: 'NAME_CONFLICT', message: 'dup' } }),
);
for (let i = 0; i < MAX_PENDING_ATTEMPTS; i++) {
await pushPendingOps();
// le backoff repousse next_retry_at : on force le reset pour re-soumettre
await h.runAsync('UPDATE pending_operations SET next_retry_at = NULL');
}
const [op] = await getPendingOperations();
assert.equal(op.status, 'failed');
assert.equal(op.attempts, MAX_PENDING_ATTEMPTS);
assert.equal(op.nextRetryAt, null); // plus jamais rejouée
});
test('listQueuedOperations honore le tri FIFO et le LIMIT', async () => {
const id1 = await enqueue({ operation: 'create_resource' });
const id2 = await enqueue({ operation: 'create_resource' });
const id3 = await enqueue({ operation: 'create_resource' });
const first = await listQueuedOperations(2);
assert.deepEqual(first.map((op) => op.id), [id1, id2]);
const later = Date.now() + 60_000;
await scheduleRetries([id2], later);
const due = await listQueuedOperations(10);
assert.deepEqual(due.map((op) => op.id), [id1, id3]); // id2 pas encore due
});
test('scheduleRetries ne change pas attempts (différent de markPendingOperation failed)', async () => {
const id1 = await enqueue({ operation: 'create_resource' });
await scheduleRetries([id1], Date.now() + 1);
const [op] = await getPendingOperations();
assert.equal(op.attempts, 0);
assert.ok((op.nextRetryAt ?? 0) > 0);
});
test('refreshPermissions : delta après = max cachedAt du snapshot', async () => {
const perms = [
{
resource_id: 'a'.repeat(32),
resourceType: 'folder',
effectiveAccess: 'owner',
inherit: true,
ownerId: 'u1',
sharedById: null,
expiresAt: null,
cachedAt: 1000,
updatedAt: 1000,
},
{
resource_id: 'b'.repeat(32),
resourceType: 'file',
effectiveAccess: 'viewer',
inherit: false,
ownerId: 'u2',
sharedById: 'u1',
expiresAt: 5,
cachedAt: 2000,
updatedAt: 2000,
},
];
stubFetch((url) => (new URL(url).searchParams.get('after') === null ? ok(perms) : ok([])));
const n1 = await refreshPermissions();
assert.equal(n1, 2);
assert.equal(pathOf(calls[0].url), '/api/v1/sync/permissions');
assert.equal((await getResourcePermission('a'.repeat(32), 'folder'))?.effectiveAccess, 'owner');
assert.equal((await getResourcePermission('b'.repeat(32), 'file'))?.expiresAt, 5);
// 2e tick (snapshot vide) → after = max cachedAt du 1er
const n2 = await refreshPermissions();
assert.equal(n2, 0);
assert.equal(pathOf(calls[1].url), '/api/v1/sync/permissions?after=2000');
});
test('refreshPermissions garde la référence dans le temps pour after', async () => {
const first = {
resource_id: 'c'.repeat(32),
resourceType: 'folder',
effectiveAccess: 'editor',
inherit: true,
ownerId: 'u1',
sharedById: null,
expiresAt: null,
cachedAt: 42,
updatedAt: 42,
};
const second = {
resource_id: 'd'.repeat(32),
resourceType: 'folder',
effectiveAccess: 'viewer',
inherit: false,
ownerId: 'u3',
sharedById: 'u1',
expiresAt: null,
cachedAt: 84,
updatedAt: 84,
};
stubFetch((url) => {
const after = new URL(url).searchParams.get('after');
return ok(after === null ? [first] : [second]);
});
await refreshPermissions();
await refreshPermissions();
assert.equal((await getResourcePermission('d'.repeat(32), 'folder'))?.effectiveAccess, 'viewer');
assert.equal(pathOf(calls[1].url), '/api/v1/sync/permissions?after=42');
});
test('outbox scopée par compte : seul le compte actif voit/pousse ses ops', async () => {
await setActiveUserId('u1');
const idA = await enqueue({ operation: 'create_resource', payload: { name: 'A' } });
await setActiveUserId('u2');
const idB = await enqueue({ operation: 'create_resource', payload: { name: 'B' } });
assert.equal(await getActiveUserId(), 'u2');
const visibleAsU2 = (await getPendingOperations()).map((op) => op.id);
assert.deepEqual(visibleAsU2, [idB], 'u2 ne voit que ses opérations');
await setActiveUserId('u1');
assert.deepEqual(
(await getPendingOperations()).map((op) => op.id),
[idA],
'u1 ne voit que ses opérations',
);
stubFetch(() => ok({ applied: 1, failed: null }));
await setActiveUserId('u2');
const result = await pushPendingOps();
assert.deepEqual(result, { pushed: 1, retried: 0 });
const body = JSON.parse(String(calls[0].init.body));
assert.equal(body.operations.length, 1, 'u2 ne pousse pas les opérations de u1');
assert.equal(body.operations[0].operation_id, idB);
await setActiveUserId('u1');
const leftover = await getPendingOperations();
assert.deepEqual(leftover.map((op) => op.id), [idA], 'lop u1 reste intacte et non-poussée');
});
test('permissions scopées par compte : snapshot et miroir par compte', async () => {
const permA = {
resource_id: 'a'.repeat(32),
resourceType: 'folder',
effectiveAccess: 'owner',
inherit: true,
ownerId: 'u1',
sharedById: null,
expiresAt: null,
cachedAt: 1000,
updatedAt: 1000,
};
await setActiveUserId('u1');
stubFetch((url) => (new URL(url).searchParams.get('after') === null ? ok([permA]) : ok([])));
await refreshPermissions();
assert.equal(
(await getResourcePermission('a'.repeat(32), 'folder'))?.effectiveAccess,
'owner',
'u1 voit la permission de son snapshot',
);
// u2 n'a pas encore de snapshot : pas d'`after` (repart du début) et la
// permission de u1 n'est pas visible tant que son propre snapshot n'est pas
// arrivé.
await setActiveUserId('u2');
assert.equal(await getResourcePermission('a'.repeat(32), 'folder'), null);
await refreshPermissions();
assert.equal(pathOf(calls[1].url), '/api/v1/sync/permissions', 'u2 repart sans after');
assert.equal(
(await getResourcePermission('a'.repeat(32), 'folder'))?.effectiveAccess,
'owner',
'u2 voit sa propre copie après son snapshot',
);
});
-6
View File
@@ -1,6 +0,0 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
}