remove react native app
This commit is contained in:
@@ -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/
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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}` : ''}`),
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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 |
@@ -1,6 +0,0 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
};
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export type SyncResult = {
|
||||
rootUri: string;
|
||||
folders: number;
|
||||
files: number;
|
||||
missing: number;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Generated
-8186
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)),
|
||||
};
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 d’un 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');
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
@@ -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, 'l’admin 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})`);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 l’implémentation d’origine', 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);
|
||||
});
|
||||
@@ -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 n’est 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,
|
||||
});
|
||||
});
|
||||
@@ -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 à l’index 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], 'l’op 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',
|
||||
);
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user