- api/types.ts : SyncOperation, SyncResult (sémantique `applied` = INDEX documentée),
ResourcePermission (miroir docs/api-v1.md §6, consommé tel quel par saveResourcePermission)
- api/client.ts : syncOps (POST /sync/ops), getSyncPermissions (GET /sync/permissions?after=),
hasAuthToken() ; INVALID_RESPONSE documenté dans docs/api-v1.md §1/§4
- services/db/repositories/pendingOps.ts : listQueuedOperations(limit) (batch FIFO due)
+ scheduleRetries(ids, retryAt) qui ne modifie JAMAIS attempts (erreur transitoire,
contrairement à markPendingOperation('failed') qui incrémente + backoff)
- features/syncOutbox.ts (nouveau) :
* pushPendingOps : batch ≤ SYNC_BATCH_SIZE (50) → ops [0, applied) passent 'completed' ;
op à l'index applied refusée → 'failed' (backoff, dead-letter MAX_PENDING_ATTEMPTS) ;
ops suivantes intactes (re-soumission) ; réseau/5xx → scheduleRetries (retried) sans
dead-letter prématurée ; applied==0 && failed → rien commité, aucun compteur touché
* refreshPermissions : delta monotone en mémoire lastPermissionCachedAt → `after`,
upsert saveResourcePermission ; no-op sans token
- features/syncDevice.ts : le tick useSyncDevice() enchaîne SAF walk → pushPendingOps()
→ refreshPermissions(), tous deux gardés par hasAuthToken()
- tests/syncOutbox.test.ts : 12 cas (FIFO/limit, succès complet/partiel, applied=0,
transitoire réseau + 5xx sans incrément, dead-letter après MAX_PENDING_ATTEMPTS,
delta snapshot croisé) — npm run test:sync
- tests/e2e.live.test.ts : smoke real backend (register → push → relecture /files/folders →
snapshot), skip si serveur down, SORTI de npm test via npm run test:e2e
- gates : npx tsc --noEmit + npm run test → 56/56 verts (24 db + 20 api + 12 sync)
6.8 KiB
6.8 KiB
VaultDrop
Project Status
Project initialized — mobile/ (React Native / Expo) and backend/ (Go) have scaffolding in place. The SQLite layer (schema v4, migrations, repositories) is implemented and covered by tests.
Architecture
- Backend: Go, Gin HTTP framework, PostgreSQL, Tesseract OCR (system call)
- Frontend: React Native (Expo SDK 57), expo-router, expo-sqlite, expo-file-system (SAF)
Key Commands
# Backend
cd backend && go run cmd/server/main.go
# PostgreSQL (via docker-compose)
docker compose up postgres -d
# Frontend
cd mobile && npx expo start
# Typecheck frontend
cd mobile && npx tsc --noEmit
# SQLite layer tests (migrations + repositories)
cd mobile && npm run test:db
Backend Structure
- Entry point:
backend/cmd/server/main.go(wiring gin + config + routes) config/— env (godotenv, optionnel) + defaults:PORT,DATABASE_URL,UPLOAD_DIR,MAX_FILE_SIZE_MB,OCR_LANG, secret pasetomodels/— domain entities (users, devices, documents/resources, clients)service/— business logic (permissions, upload, create folder, move, sync outbox + snapshot)handlers/— HTTP handlers (health, devices register + paseto, files CRUD/upload/search, folders, sync/ops + sync/permissions, ocr/jobs — réels)repository/— Postgres persistence réelle (repository.Resources: insert/list/get/soft-delete scopingowner_id, search, move, rename, root-name unique index,repository.Devices.Upsert,repository.Operations: trace outbox idempotente(device_id, operation_id),ListOwnedpour le snapshot,repository.OcrJobs: jobs queued→processing→done/failed) ; IDs sont TEXT 32-hex,NewID()=crypto/rand16 octets hex (jamais UUID conversion, cf.docs/api-v1.md)db/— package migrations (golang-migrate/v4, embarquées viaembeddansdb/migrations/*.sql) :db.MigrateDatabase(url)au boot du serveur ; test harnessdb/migrations_test.go(up → assertions schéma → down,TEST_DATABASE_URL, skip si PG indisponible) ;dbtest/— helper cross-package pour les tests repo/handlers (crée la DB test si absente, reset schema, migrate ; skip si PG down)ocr/— OCR engine behind an interface (Tesseract system call,OCR_LANGdéfautfra+eng)- Response helpers:
pkg/api/response.go - File uploads stored in
backend/uploads/ - Standard JSON response envelope:
{ "data": ..., "meta": { "page": ..., "total": ... } } - Error format:
{ "error": { "code": "...", "message": "..." } } - Route list is a tracked contract (
cmd/server/router_test.gomirrorsmobile/api/client.ts)
Frontend Structure
- Entry point:
mobile/App.tsx(expo-router layout + Auth context) - Data layer —
mobile/services/:safDirectory.ts+safDirectory.types.ts: physical access viaexpo-file-system(pick/list/create, Documents/SAF uris)db/— SQLite persistence, seemobile/AGENTS.mdfor the full contract (schema, migrations, repositories, tests)localStorage.ts— thin re-export ofservices/db(legacy alias)
features/syncDevice.ts— device sync orchestration (two-pass SAF walk, single transaction per root,exists = 0reconciliation)features/syncOutbox.ts— pullspending_operationstoPOST /sync/ops(resume-at-appliedindex, transient retries never bumpattempts) andGET /sync/permissionsdelta snapshot; both no-ops without an auth tokencontext/AuthContext.tsx— session context: exposesdeviceUserId(bootstrapped fromgetDeviceUserId()) and starts the backgroundsyncDeviceloopapp/— expo-router screens:index.tsx(dossiers racines + ajout SAF),folder/[id].tsx(sous-dossiers + fichiers)api/— REST client (client.tsfetch wrapper +types.ts= contrat d'API : enveloppe{ data, meta }, erreurs{ error: { code, message } })hooks/— TanStack Query hooks:useFiles,useSearch,useUpload(+ OCR jobs)- No business logic on the client — heavy processing stays server-side
- API base URL via
EXPO_PUBLIC_API_BASE_URL(défauthttp://localhost:8080/api/v1)
Data Conventions
- Canonical identity for folders/files/shares/share_links is
resource_id: opaquelower(hex(randomblob(16))), generated locally, never reused. The physicaluriis nullable (NULL = cloud-only) and is the reconciliation key for the SAF walk. owner_idis NOT NULL on every folder/file row, seeded from the device'sdevice_user_id.- Folder/file
sync_statusis a placement state:local|cloud|local-cloud(transitions viatransitionSyncStatus). It is not a push progress marker. - Shares/share_links carry no
sync_status; theirpushStatus(pending/synced/failed) is derived from thepending_operationsoutbox. - Decisions are made offline from a cached
resource_permissionssnapshot pushed by the server; the server remains the source of truth.canAccessenforces ranking (viewer < commenter < editor < owner),inherit,expires_at, and a 24h stale-cache read-only downgrade. password_hashand download counters are server-side only; the client only stores thehas_passwordboolean and a counter mirror.
API Contract (V1)
- The mobile client is the contract: endpoint shapes in
mobile/api/types.ts+mobile/api/client.tsare authoritative and must match exactly; the server does not renegotiate them. Consolidated spec:docs/api-v1.md. - Identity: device-first. The device registers (
POST /devices) and authenticates with a paseto bearer token; no user accounts in V1 (userstable exists butdevices.user_idstays NULL). - Identifiers:
resource_id/device_user_id/ share-linktokenare opaque lowercase 32-hex TEXT (^[0-9a-f]{32}$, CHECK-enforced), stored as-is server-side (no UUID conversion). The mobile always generateslower(hex(randomblob(16))). - Outbox idempotence + ordering: client pushes batches of
pending_operations; each operation carriesoperation_id(= clientpending_operations.id), server enforces uniqueness per device. Batches are applied sequentially; the server stops at the first non-idempotent failure and returns the index reached so the client resumes there (outbox retry/backoff can reorder). - Permissions snapshot: the server pushes
resource_permissionssnapshots (effective_accessranking viewer < commenter < editor < owner,inherit,expires_at, TTL 24h → read-only downgrade) that the offlinecanAccessconsumes.
Non-Goals (V1)
- Plugin system
- On-device OCR
- Full multi-tenant federation / public discovery
- Multi-writer sync conflicts (single-owner device identity; device-local
device_user_id)
References
docs/api-v1.md— contrat API V1 (autoritatif, consolidé depuismobile/api/types.ts)V2.md— modèle cible Postgres/ReBAC (identifiants en TEXT 32-hex, cf.docs/api-v1.md)