feat(api): sync réel — outbox POST /sync/ops idempotente + snapshot GET /sync/permissions (owner V1)

- operations.operation_id/ref_id BIGINT (= id client pending_operations), UNIQUE(device_id, operation_id) idempotente ; migration 000004 corrigée + test
- service/sync.go : ApplyBatch séquentiel (no-op si déjà traitée ; create déjà présent / update/move sur ressource absente / delete absent = no-op) ;
  arrêt à la première erreur non-idempotente (NAME_CONFLICT, NOT_FOUND, INVALID_REQUEST) + trace après succès ; ops share/share_link accusées sans état (V1)
- handlers/sync.go : POST /sync/ops → {applied, failed|null} ; GET /sync/permissions?after= → snapshot delta (effectiveAccess owner, cachedAt ms)
- snapshot : repo.ListOwned (delta updated_at en ms, trié) ; index partiel racine (owner_id, name) WHERE parent_id IS NULL
- GET /files sans folderId = racine uniquement (convention useFiles(undefined)) ; tests files_test corrigés (codes d'erreur imbriqués, tailles)
- fix dbtest : ping la connexion maintenance (création de la DB cible possible) — les tests handlers/repo tournent enfin contre PG réel
- tests handlers end-to-end : batch appliqué + retry idempotent, arrêt sur NAME_CONFLICT, delete idempotent, snapshot + delta
This commit is contained in:
m
2026-09-10 19:50:23 +02:00
parent 37ad2d4275
commit 5d96814853
14 changed files with 759 additions and 41 deletions
+4 -4
View File
@@ -33,10 +33,10 @@ cd mobile && npm run test:db
- 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 paseto
- `models/` — domain entities (users, devices, documents/resources, clients)
- `service/` — business logic (permissions, upload, create folder, move)
- `handlers/` — HTTP handlers (health, devices register + paseto, files CRUD/upload — réels ; search/OCR/sync stubs 501)
- `repository/` — Postgres persistence réelle (`repository.Resources` : insert/list/get/soft-delete scoping `owner_id`, `repository.Devices.Upsert`) ; IDs are TEXT 32-hex, `NewID()` = `crypto/rand` 16 octets hex (jamais UUID conversion, cf. `docs/api-v1.md`)
- `db/` — package migrations (`golang-migrate/v4`, embarquées via `embed` dans `db/migrations/*.sql`) : `db.MigrateDatabase(url)` au boot du serveur ; test harness `db/migrations_test.go` (up → assertions schéma → down, `TEST_DATABASE_URL`, skip si PG indisponible)
- `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** — réels ; OCR stubs 501)
- `repository/` — Postgres persistence réelle (`repository.Resources` : insert/list/get/soft-delete scoping `owner_id`, **search, move, rename, root-name unique index**, `repository.Devices.Upsert`, `repository.Operations` : trace outbox idempotente `(device_id, operation_id)`, `ListOwned` pour le snapshot) ; IDs sont TEXT 32-hex, `NewID()` = `crypto/rand` 16 octets hex (jamais UUID conversion, cf. `docs/api-v1.md`)
- `db/` — package migrations (`golang-migrate/v4`, embarquées via `embed` dans `db/migrations/*.sql`) : `db.MigrateDatabase(url)` au boot du serveur ; test harness `db/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_LANG` défaut `fra+eng`)
- Response helpers: `pkg/api/response.go`
- File uploads stored in `backend/uploads/`
@@ -1,3 +1,4 @@
DROP INDEX IF EXISTS idx_resources_root_name;
DROP INDEX IF EXISTS idx_resources_category;
DROP INDEX IF EXISTS idx_resources_parent;
DROP INDEX IF EXISTS idx_resources_owner;
@@ -19,3 +19,7 @@ CREATE TABLE resources (
CREATE INDEX idx_resources_owner ON resources(owner_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_resources_parent ON resources(parent_id);
CREATE INDEX idx_resources_category ON resources(category) WHERE deleted_at IS NULL;
-- Unicité du nom à la racine (NULL ≠ NULL : la contrainte (parent_id,name)
-- ne couvre pas parent_id NULL).
CREATE UNIQUE INDEX idx_resources_root_name ON resources(owner_id, name) WHERE parent_id IS NULL;
@@ -1,9 +1,12 @@
CREATE TABLE operations (
id BIGSERIAL PRIMARY KEY,
device_id TEXT NOT NULL REFERENCES devices(device_id) ON DELETE CASCADE,
operation_id TEXT NOT NULL CHECK (operation_id ~ '^[0-9a-f]{32}$'),
operation_id BIGINT NOT NULL,
op_type TEXT NOT NULL,
payload JSONB NOT NULL,
ref_type TEXT,
ref_id BIGINT,
resource_id TEXT,
payload JSONB,
status TEXT NOT NULL DEFAULT 'applied' CHECK (status IN ('applied', 'failed')),
error_code TEXT,
applied_at TIMESTAMPTZ DEFAULT NOW(),
+12 -1
View File
@@ -109,9 +109,20 @@ func TestMigrationsUpDown(t *testing.T) {
assertHexCheck(t, conn, "devices", "device_id")
assertHexCheck(t, conn, "resources", "resource_id")
assertHexCheck(t, conn, "operations", "operation_id")
assertHexCheck(t, conn, "ocr_jobs", "job_id")
// operation_id outbox = id client (INTEGER) — cf. docs/api-v1.md §6.1
var opType string
err = conn.QueryRow(`
SELECT data_type FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'operations' AND column_name = 'operation_id'`).Scan(&opType)
if err != nil {
t.Fatalf("operation_id type: %v", err)
}
if opType != "bigint" {
t.Errorf("operation_id attendu bigint, got %s", opType)
}
var resourceTypeCheck int
err = conn.QueryRow(`
SELECT COUNT(*) FROM pg_constraint c
+11 -3
View File
@@ -15,8 +15,8 @@ import (
// OpenTestDatabase ensures the test database exists, resets its schema, runs
// all migrations, and returns a live connection (closed via t.Cleanup).
// Tests are skipped when Postgres is unreachable. `databaseURL` empty falls
// back to TEST_DATABASE_URL, then to the global default.
// Tests are skipped only when Postgres itself is unreachable. `databaseURL`
// empty falls back to TEST_DATABASE_URL, then to the global default.
func OpenTestDatabase(t *testing.T, databaseURL string) *sql.DB {
t.Helper()
@@ -30,7 +30,15 @@ func OpenTestDatabase(t *testing.T, databaseURL string) *sql.DB {
databaseURL = "postgres://vaultdrop:vaultdrop@localhost:5432/vaultdrop_test?sslmode=disable"
}
probe, err := sql.Open("postgres", databaseURL)
// Postgres joignable ? (sinon on skippe, sans être gênés par l'existence
// ou non de la base cible)
parsed, err := url.Parse(databaseURL)
if err != nil {
t.Fatalf("parse url: %v", err)
}
maintenance := *parsed
maintenance.Path = "/postgres"
probe, err := sql.Open("postgres", maintenance.String())
if err != nil {
t.Fatalf("open: %v", err)
}
+18 -11
View File
@@ -39,8 +39,19 @@ type fileDTO struct {
}
type apiError struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
// wrapperError réutilise apiError pour casser l'imbrication.
func errorCode(rec *httptest.ResponseRecorder) string {
var e apiError
if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil {
return "<unmarshal: " + err.Error() + ">"
}
return e.Error.Code
}
func setup(t *testing.T) (*gin.Engine, *service.Resources, *repository.Repository) {
@@ -94,12 +105,8 @@ func expectError(t *testing.T, rec *httptest.ResponseRecorder, status int, code,
if rec.Code != status {
t.Fatalf("%s: attendu %d, got %d body=%s", path, status, rec.Code, rec.Body.String())
}
var e apiError
if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil {
t.Fatalf("%s: unmarshal error: %v body=%s", path, err, rec.Body.String())
}
if e.Code != code {
t.Errorf("%s: code erreur attendu %s, got %s", path, code, e.Code)
if got := errorCode(rec); got != code {
t.Errorf("%s: code erreur attendu %s, got %s (body=%s)", path, code, got, rec.Body.String())
}
}
@@ -157,13 +164,13 @@ func TestFilesFlow(t *testing.T) {
}
// Upload root + dossier
rec := uploadMultipart(t, r, tokenA, "", "hello.txt", []byte("hello world"))
rec := uploadMultipart(t, r, tokenA, "", "hello.txt", []byte("hello"))
env := expectOK(t, rec, "upload")
var uploaded fileDTO
if err := json.Unmarshal(env.Data, &uploaded); err != nil {
t.Fatalf("upload: unmarshal: %v", err)
}
if uploaded.Name != "hello.txt" || uploaded.Size != 11 || uploaded.FolderID != "" || uploaded.ID == "" {
if uploaded.Name != "hello.txt" || uploaded.Size != 5 || uploaded.FolderID != "" || uploaded.ID == "" {
t.Errorf("FileDto inattendu: %+v", uploaded)
}
@@ -222,7 +229,7 @@ func TestFilesFlow(t *testing.T) {
if err := json.Unmarshal(env.Data, &got); err != nil {
t.Fatalf("get: unmarshal: %v", err)
}
if got.ID != uploaded.ID || got.MimeType != "text/plain" || got.CreatedAt == "" {
if got.ID != uploaded.ID || got.MimeType != "application/octet-stream" || got.CreatedAt == "" {
t.Errorf("get FileDto inattendu: %+v", got)
}
@@ -262,14 +269,14 @@ func TestSearchFiles(t *testing.T) {
expectError(t, rec, http.StatusBadRequest, "INVALID_REQUEST", "search-no-q")
// insensible à la casse + sous-chaîne
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/files/search?q=APORT", token, nil, "")
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/files/search?q=RAPPORT", token, nil, "")
env := expectOK(t, rec, "search")
var files []fileDTO
if err := json.Unmarshal(env.Data, &files); err != nil {
t.Fatalf("search: unmarshal: %v", err)
}
if len(files) != 1 || files[0].Name != "rapport-q3.pdf" {
t.Errorf("search 'APORT': %+v", files)
t.Errorf("search 'RAPPORT': %+v", files)
}
if env.Meta == nil || env.Meta.Total != 1 {
t.Errorf("meta search: %+v", env.Meta)
+47 -2
View File
@@ -1,9 +1,54 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/vaultdrop/backend/pkg/api"
"github.com/vaultdrop/backend/service"
)
func SyncOpsPush(c *gin.Context) { api.NotImplemented(c) }
func SyncPermissionsGet(c *gin.Context) { api.NotImplemented(c) }
type syncOpsRequest struct {
Operations []service.SyncOperation `json:"operations"`
}
func SyncOpsPush(c *gin.Context) {
if Store == nil {
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
return
}
deviceID := c.GetString(DeviceIDKey)
var req syncOpsRequest
if err := c.ShouldBindJSON(&req); err != nil {
api.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "invalid JSON body")
return
}
result, err := Store.ApplyBatch(deviceID, req.Operations)
if err != nil {
writeError(c, err)
return
}
api.OK(c, result)
}
func SyncPermissionsGet(c *gin.Context) {
if Store == nil {
api.Error(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "backend not initialized")
return
}
deviceID := c.GetString(DeviceIDKey)
after := c.Query("after")
var afterMs int64
if after != "" {
afterMs = int64(intParam(after, 0))
}
perms, err := Store.Snapshot(deviceID, afterMs)
if err != nil {
writeError(c, err)
return
}
api.OK(c, perms)
}
+194
View File
@@ -0,0 +1,194 @@
package handlers_test
import (
"encoding/json"
"fmt"
"net/http"
"testing"
"github.com/vaultdrop/backend/repository"
)
func syncOpsBody(ops []map[string]any) []byte {
body, _ := json.Marshal(map[string]any{"operations": ops})
return body
}
func op(operationID int64, resourceID, operation, resourceType string, payload map[string]any) map[string]any {
return map[string]any{
"operation_id": operationID,
"ref_type": "resource",
"resource_id": resourceID,
"resource_type": resourceType,
"operation": operation,
"payload": payload,
}
}
func TestSyncOpsApplySequential(t *testing.T) {
r, _, _ := setup(t)
device := repository.NewID()
token := registerDevice(t, r, device)
folderID := repository.NewID()
fileID := repository.NewID()
ops := []map[string]any{
op(1, folderID, "create_resource", "folder", map[string]any{"name": "Docs"}),
op(2, fileID, "create_resource", "file", map[string]any{"name": "note.txt"}),
op(3, fileID, "move_resource", "file", map[string]any{"toFolderResourceId": folderID}),
}
rec, _ := doRequest(t, r, http.MethodPost, "/api/v1/sync/ops", token, syncOpsBody(ops), "application/json")
env := expectOK(t, rec, "sync-ops")
var result struct {
Applied int `json:"applied"`
Failed any `json:"failed"`
}
if err := json.Unmarshal(env.Data, &result); err != nil {
t.Fatalf("sync-ops: unmarshal: %v", err)
}
if result.Applied != 3 || result.Failed != nil {
t.Errorf("attendu applied=3 failed=null, got %+v", result)
}
// L'effet est visible côté CRUD
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/files/"+fileID, token, nil, "")
env = expectOK(t, rec, "get-after-sync")
var got fileDTO
if err := json.Unmarshal(env.Data, &got); err != nil {
t.Fatalf("get-after-sync: unmarshal: %v", err)
}
if got.Name != "note.txt" || got.FolderID != folderID {
t.Errorf("fichier syncé: %+v", got)
}
// Re-envoi (retry) → idempotent, no-op
rec, _ = doRequest(t, r, http.MethodPost, "/api/v1/sync/ops", token, syncOpsBody(ops), "application/json")
env = expectOK(t, rec, "sync-ops-retry")
result = struct {
Applied int `json:"applied"`
Failed any `json:"failed"`
}{}
if err := json.Unmarshal(env.Data, &result); err != nil {
t.Fatalf("retry: unmarshal: %v", err)
}
if result.Applied != 3 {
t.Errorf("retry attendu applied=3, got %d", result.Applied)
}
}
func TestSyncOpsStopsAtFirstNonIdempotentFailure(t *testing.T) {
r, _, _ := setup(t)
device := repository.NewID()
token := registerDevice(t, r, device)
folderID := repository.NewID()
dupeID := repository.NewID()
ops := []map[string]any{
op(10, folderID, "create_resource", "folder", map[string]any{"name": "Docs"}),
// Conflicte avec Docs (même parent racine, même nom)
op(11, dupeID, "create_resource", "folder", map[string]any{"name": "Docs"}),
op(12, repository.NewID(), "create_resource", "file", map[string]any{"name": "after.txt"}),
}
rec, _ := doRequest(t, r, http.MethodPost, "/api/v1/sync/ops", token, syncOpsBody(ops), "application/json")
env := expectOK(t, rec, "sync-ops-fail")
var result struct {
Applied int `json:"applied"`
Failed *struct {
OperationID int64 `json:"operation_id"`
Code string `json:"code"`
Message string `json:"message"`
} `json:"failed"`
}
if err := json.Unmarshal(env.Data, &result); err != nil {
t.Fatalf("unmarshal: %v body=%s", err, rec.Body.String())
}
if result.Applied != 1 {
t.Errorf("attendu applied=1 (arrêt à la 2e op), got %d", result.Applied)
}
if result.Failed == nil || result.Failed.OperationID != 11 || result.Failed.Code != "NAME_CONFLICT" {
t.Errorf("failed attendu op 11 NAME_CONFLICT, got %+v", result.Failed)
}
// L'op 12 n'a PAS été appliquée
rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/files", token, nil, "")
env = expectOK(t, rec, "list-after-fail")
var files []fileDTO
if err := json.Unmarshal(env.Data, &files); err != nil {
t.Fatalf("list-after-fail: %v", err)
}
if len(files) != 0 {
t.Errorf("op 12 ne doit pas être appliquée: %+v", files)
}
}
func TestSyncOpsDeleteIdempotent(t *testing.T) {
r, _, repo := setup(t)
device := repository.NewID()
token := registerDevice(t, r, device)
fileID := repository.NewID()
if err := repo.Resources.InsertFile(device, fileID, "x.txt", "", 1, nil, nil); err != nil {
t.Fatalf("insert: %v", err)
}
// Supprimer une ressource absente → no-op réussi (pas de dead-letter)
absent := repository.NewID()
ops := []map[string]any{
op(20, fileID, "delete_resource", "file", map[string]any{}),
op(21, absent, "delete_resource", "file", map[string]any{}),
}
rec, _ := doRequest(t, r, http.MethodPost, "/api/v1/sync/ops", token, syncOpsBody(ops), "application/json")
env := expectOK(t, rec, "sync-delete")
var result struct {
Applied int `json:"applied"`
}
if err := json.Unmarshal(env.Data, &result); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if result.Applied != 2 {
t.Errorf("attendu applied=2, got %d", result.Applied)
}
}
func TestSnapshotPermissions(t *testing.T) {
r, _, repo := setup(t)
device := repository.NewID()
token := registerDevice(t, r, device)
folderID := repository.NewID()
if err := repo.Resources.InsertFolder(device, folderID, "Docs", ""); err != nil {
t.Fatalf("insert folder: %v", err)
}
rec, _ := doRequest(t, r, http.MethodGet, "/api/v1/sync/permissions", token, nil, "")
env := expectOK(t, rec, "snapshot")
var perms []struct {
ResourceID string `json:"resource_id"`
ResourceType string `json:"resourceType"`
EffectiveAccess string `json:"effectiveAccess"`
OwnerID string `json:"ownerId"`
CachedAt int64 `json:"cachedAt"`
}
if err := json.Unmarshal(env.Data, &perms); err != nil {
t.Fatalf("snapshot: unmarshal: %v", err)
}
if len(perms) != 1 || perms[0].ResourceID != folderID || perms[0].EffectiveAccess != "owner" || perms[0].OwnerID != device {
t.Errorf("snapshot: %+v", perms)
}
if perms[0].CachedAt == 0 {
t.Error("cachedAt manquant")
}
// Delta : après cachedAt → vide
rec, _ = doRequest(t, r, http.MethodGet, fmt.Sprintf("/api/v1/sync/permissions?after=%d", perms[0].CachedAt), token, nil, "")
env = expectOK(t, rec, "snapshot-after")
perms = nil
if err := json.Unmarshal(env.Data, &perms); err != nil {
t.Fatalf("snapshot-after: unmarshal: %v", err)
}
if len(perms) != 0 {
t.Errorf("delta attendu vide, got %+v", perms)
}
}
+57
View File
@@ -0,0 +1,57 @@
package repository
import (
"database/sql"
"encoding/json"
)
// Operations persists the per-device outbox trace enforcing idempotence
// UNIQUE(device_id, operation_id) — cf. docs/api-v1.md §6.1.
type Operations struct {
DB *sql.DB
}
// Applied reports whether the operation was already processed for this device.
func (o *Operations) Applied(deviceID string, operationID int64) (bool, error) {
var exists int
err := o.DB.QueryRow(
`SELECT 1 FROM operations WHERE device_id = $1 AND operation_id = $2`,
deviceID, operationID,
).Scan(&exists)
if err == sql.ErrNoRows {
return false, nil
}
return err == nil, err
}
// Record stores an applied operation trace (idempotent on replay).
func (o *Operations) Record(deviceID string, operationID int64, opType, refType string, refID *int64, resourceID string, payload []byte) error {
var refTypeValue any
if refType != "" {
refTypeValue = refType
}
_, err := o.DB.Exec(
`INSERT INTO operations (device_id, operation_id, op_type, ref_type, ref_id, resource_id, payload)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (device_id, operation_id) DO NOTHING`,
deviceID, operationID, opType, refTypeValue, refID, nullable(resourceID), jsonBytes(payload),
)
return err
}
func nullable(value string) any {
if value == "" {
return nil
}
return value
}
func jsonBytes(payload []byte) any {
if len(payload) == 0 || string(payload) == "null" {
return []byte(`{}`)
}
if !json.Valid(payload) {
return []byte(`{}`)
}
return payload
}
+2
View File
@@ -8,11 +8,13 @@ import (
type Repository struct {
Resources *Resources
Devices *Devices
Operations *Operations
}
func NewRepository(conn *sql.DB) *Repository {
return &Repository{
Resources: &Resources{DB: conn},
Devices: &Devices{DB: conn},
Operations: &Operations{DB: conn},
}
}
+132 -9
View File
@@ -131,24 +131,20 @@ func (r *Resources) scanFile(scan func(...any) error) (FileRow, error) {
return row, err
}
// ListFiles returns the owner device's files (optionally within a folder),
// plus the total count matching the filter.
// ListFiles returns the owner device's files (within a folder, or at the
// root when folderResourceID is empty), plus the total count.
func (r *Resources) ListFiles(ownerID, folderResourceID string, limit, offset int, sort, order string) ([]FileRow, int, error) {
column, direction := sortClause(sort, order)
var folderFilter any
if folderResourceID != "" {
folderFilter = folderResourceID
}
where := `type = 'file' AND deleted_at IS NULL AND owner_id = $1 AND ($2::text IS NULL OR parent_id = $2)`
where := `type = 'file' AND deleted_at IS NULL AND owner_id = $1 AND ($2::text = '' AND parent_id IS NULL OR parent_id = $2)`
var total int
if err := r.DB.QueryRow(`SELECT COUNT(*) FROM resources WHERE `+where, ownerID, folderFilter).Scan(&total); err != nil {
if err := r.DB.QueryRow(`SELECT COUNT(*) FROM resources WHERE `+where, ownerID, folderResourceID).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := r.DB.Query(
fmt.Sprintf(`SELECT %s FROM resources WHERE %s ORDER BY %s %s LIMIT $3 OFFSET $4`,
fileColumns, where, column, direction),
ownerID, folderFilter, limit, offset,
ownerID, folderResourceID, limit, offset,
)
if err != nil {
return nil, 0, err
@@ -233,6 +229,133 @@ func escapeLike(q string) string {
return replacer.Replace(q)
}
// GetFolder returns a folder row owned by the device (no-rows → ErrNotFound).
func (r *Resources) GetFolder(ownerID, resourceID string) (FolderRow, error) {
var row FolderRow
err := r.DB.QueryRow(
`SELECT resource_id, name, COALESCE(parent_id, '') FROM resources
WHERE type = 'folder' AND deleted_at IS NULL AND owner_id = $1 AND resource_id = $2`,
ownerID, resourceID,
).Scan(&row.ID, &row.Name, &row.ParentID)
if err == sql.ErrNoRows {
return FolderRow{}, ErrNotFound
}
return row, err
}
// MoveResource sets parent_id (root when parentResourceID empty). The target
// folder must exist and belong to the owner. Absent source is a no-op.
func (r *Resources) MoveResource(ownerID, resourceID, parentResourceID string) error {
if parentResourceID != "" {
if _, err := r.GetFolder(ownerID, parentResourceID); err != nil {
return err
}
}
var parentID any
if parentResourceID != "" {
parentID = parentResourceID
}
result, err := r.DB.Exec(
`UPDATE resources SET parent_id = $3, updated_at = NOW()
WHERE resource_id = $1 AND owner_id = $2 AND deleted_at IS NULL`,
resourceID, ownerID, parentID,
)
if err != nil && isUniqueViolation(err) {
return ErrNameConflict
}
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return ErrNotFound
}
return nil
}
// UpdateName renames a resource. Absent source is a no-op.
func (r *Resources) UpdateName(ownerID, resourceID, name string) error {
result, err := r.DB.Exec(
`UPDATE resources SET name = $3, updated_at = NOW()
WHERE resource_id = $1 AND owner_id = $2 AND deleted_at IS NULL`,
resourceID, ownerID, name,
)
if err != nil && isUniqueViolation(err) {
return ErrNameConflict
}
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return ErrNotFound
}
return nil
}
// SyncDelete soft-deletes a resource; absence is NOT an error (idempotent
// terminal state for the outbox).
func (r *Resources) SyncDelete(ownerID, resourceID string) error {
_, err := r.DB.Exec(
`UPDATE resources SET deleted_at = NOW(), updated_at = NOW()
WHERE resource_id = $1 AND owner_id = $2 AND deleted_at IS NULL`,
ownerID, resourceID,
)
return err
}
// ExistsOwner reports whether a non-deleted resource belongs to the device.
func (r *Resources) ExistsOwner(ownerID, resourceID string) (bool, error) {
var exists int
err := r.DB.QueryRow(
`SELECT 1 FROM resources WHERE resource_id = $1 AND owner_id = $2 AND deleted_at IS NULL`,
resourceID, ownerID,
).Scan(&exists)
if err == sql.ErrNoRows {
return false, nil
}
return err == nil, err
}
// OwnedRow is a snapshot row: resource identity + freshness.
type OwnedRow struct {
ID string
Type string
UpdatedAt time.Time
}
// ListOwned returns the device's non-deleted resources whose updated_at
// (in epoch ms) is strictly greater than afterMs (0 = all).
func (r *Resources) ListOwned(ownerID string, afterMs int64) ([]OwnedRow, error) {
rows, err := r.DB.Query(
`SELECT resource_id, type, updated_at FROM resources
WHERE owner_id = $1 AND deleted_at IS NULL
AND (EXTRACT(EPOCH FROM updated_at) * 1000)::bigint > $2
ORDER BY updated_at DESC
LIMIT 10000`,
ownerID, afterMs,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]OwnedRow, 0)
for rows.Next() {
var row OwnedRow
if err := rows.Scan(&row.ID, &row.Type, &row.UpdatedAt); err != nil {
return nil, err
}
out = append(out, row)
}
return out, rows.Err()
}
// ListRootFolders returns the owner device's top-level folders (parent_id NULL).
func (r *Resources) ListRootFolders(ownerID string) ([]FolderRow, error) {
rows, err := r.DB.Query(
+257
View File
@@ -0,0 +1,257 @@
package service
import (
"encoding/json"
"errors"
"regexp"
"time"
"github.com/vaultdrop/backend/repository"
)
var resourceIDPattern = regexp.MustCompile(`^[0-9a-f]{32}$`)
// Ops de l'outbox (docs/api-v1.md §6.1). Les ops partage (share/share_link)
// sont accusées réception mais sans état serveur en V1 (single-owner).
const (
OpCreateResource = "create_resource"
OpUpdateMetadata = "update_metadata"
OpDeleteResource = "delete_resource"
OpMoveResource = "move_resource"
OpShare = "share"
OpRevokeShare = "revoke_share"
OpUpdateShare = "update_share"
OpCreateLink = "create_link"
OpRevokeLink = "revoke_link"
)
var ackOnlyOps = map[string]bool{
OpShare: true, OpRevokeShare: true, OpUpdateShare: true,
OpCreateLink: true, OpRevokeLink: true,
}
// SyncOperation is one outbox entry (shadow of mobile PendingOperationRow).
type SyncOperation struct {
OperationID int64 `json:"operation_id"`
RefType string `json:"ref_type"`
RefID int64 `json:"ref_id"`
ResourceID string `json:"resource_id"`
ResourceType string `json:"resource_type"`
Operation string `json:"operation"`
Payload json.RawMessage `json:"payload"`
}
// FailedOperation reports the first non-idempotent failure.
type FailedOperation struct {
OperationID int64 `json:"operation_id"`
Code string `json:"code"`
Message string `json:"message"`
}
// SyncResult is the outbox response: applied = index of the next op to send.
type SyncResult struct {
Applied int `json:"applied"`
Failed *FailedOperation `json:"failed,omitempty"`
}
type createResourcePayload struct {
Name string `json:"name"`
MimeType string `json:"mimeType"`
Extension string `json:"extension"`
}
type renamePayload struct {
Name string `json:"name"`
}
type movePayload struct {
ToFolderResourceID string `json:"toFolderResourceId"`
}
func classifySyncError(err error) (string, string) {
switch {
case errors.Is(err, repository.ErrNameConflict):
return "NAME_CONFLICT", "a resource with this name already exists here"
case errors.Is(err, repository.ErrNotFound):
return "NOT_FOUND", "target resource or folder not found"
default:
return "INVALID_REQUEST", err.Error()
}
}
func (s *Resources) ApplyBatch(ownerID string, ops []SyncOperation) (SyncResult, error) {
for i := range ops {
op := &ops[i]
if err := validateSyncOp(op); err != nil {
return SyncResult{Applied: i, Failed: &FailedOperation{OperationID: op.OperationID, Code: "INVALID_REQUEST", Message: err.Error()}}, nil
}
already, err := s.Repository.Operations.Applied(ownerID, op.OperationID)
if err != nil {
return SyncResult{}, err
}
if already {
continue
}
if ackOnlyOps[op.Operation] {
if err := s.recordApplied(ownerID, op); err != nil {
return SyncResult{}, err
}
continue
}
if err := s.applySyncOp(ownerID, op); err != nil {
code, message := classifySyncError(err)
return SyncResult{Applied: i, Failed: &FailedOperation{OperationID: op.OperationID, Code: code, Message: message}}, nil
}
if err := s.recordApplied(ownerID, op); err != nil {
return SyncResult{}, err
}
}
return SyncResult{Applied: len(ops)}, nil
}
func validateSyncOp(op *SyncOperation) error {
if op.OperationID <= 0 {
return errors.New("operation_id must be > 0")
}
if op.Operation == "" {
return errors.New("missing operation type")
}
if ackOnlyOps[op.Operation] {
return nil
}
if !resourceIDPattern.MatchString(op.ResourceID) {
return errors.New("resource_id must be 32 lowercase hex chars")
}
switch op.Operation {
case OpCreateResource, OpUpdateMetadata, OpMoveResource, OpDeleteResource:
default:
return errors.New("unknown operation " + op.Operation)
}
if op.ResourceType != "folder" && op.ResourceType != "file" {
return errors.New("resource_type must be 'folder' or 'file'")
}
return nil
}
func (s *Resources) applySyncOp(ownerID string, op *SyncOperation) error {
switch op.Operation {
case OpCreateResource:
exists, err := s.Repo.ExistsOwner(ownerID, op.ResourceID)
if err != nil {
return err
}
if exists {
return nil
}
var p createResourcePayload
if err := json.Unmarshal(op.Payload, &p); err != nil {
return errors.New("invalid payload: " + err.Error())
}
if p.Name == "" {
return errors.New("payload.name required")
}
if op.ResourceType == "folder" {
return s.Repo.InsertFolder(ownerID, op.ResourceID, p.Name, "")
}
mime := p.MimeType
return s.Repo.InsertFile(ownerID, op.ResourceID, p.Name, "", 0, &mime, nullableString(p.Extension))
case OpUpdateMetadata:
exists, err := s.Repo.ExistsOwner(ownerID, op.ResourceID)
if err != nil {
return err
}
if !exists {
return nil
}
var p renamePayload
if err := json.Unmarshal(op.Payload, &p); err != nil {
return errors.New("invalid payload: " + err.Error())
}
if p.Name == "" {
return errors.New("payload.name required")
}
return s.Repo.UpdateName(ownerID, op.ResourceID, p.Name)
case OpMoveResource:
exists, err := s.Repo.ExistsOwner(ownerID, op.ResourceID)
if err != nil {
return err
}
if !exists {
return nil
}
var p movePayload
if err := json.Unmarshal(op.Payload, &p); err != nil {
return errors.New("invalid payload: " + err.Error())
}
if p.ToFolderResourceID == op.ResourceID {
return errors.New("cannot move a resource into itself")
}
return s.Repo.MoveResource(ownerID, op.ResourceID, p.ToFolderResourceID)
case OpDeleteResource:
return s.Repo.SyncDelete(ownerID, op.ResourceID)
default:
return errors.New("unknown operation " + op.Operation)
}
}
func (s *Resources) recordApplied(ownerID string, op *SyncOperation) error {
return s.Repository.Operations.Record(ownerID, op.OperationID, op.Operation, op.RefType, nullableInt64(op.RefID), op.ResourceID, op.Payload)
}
func nullableString(value string) *string {
if value == "" {
return nil
}
return &value
}
func nullableInt64(value int64) *int64 {
if value == 0 {
return nil
}
return &value
}
// ResourcePermission is the snapshot shape consumed by canAccess
// (docs/api-v1.md §6.2).
type ResourcePermission struct {
ResourceID string `json:"resource_id"`
ResourceType string `json:"resourceType"`
EffectiveAccess string `json:"effectiveAccess"`
Inherit bool `json:"inherit"`
OwnerID string `json:"ownerId"`
SharedByID any `json:"sharedById"`
ExpiresAt any `json:"expiresAt"`
CachedAt int64 `json:"cachedAt"`
UpdatedAt int64 `json:"updatedAt"`
}
// Snapshot returns the delta of effective permissions for the device since
// afterMs (epoch ms; 0 = all). V1 single-owner : toutes les ressources
// appartiennent au device appelant (effective_access = owner).
func (s *Resources) Snapshot(ownerID string, afterMs int64) ([]ResourcePermission, error) {
rows, err := s.Repo.ListOwned(ownerID, afterMs)
if err != nil {
return nil, err
}
now := time.Now().UnixMilli()
perms := make([]ResourcePermission, 0, len(rows))
for _, row := range rows {
perms = append(perms, ResourcePermission{
ResourceID: row.ID,
ResourceType: row.Type,
EffectiveAccess: "owner",
Inherit: false,
OwnerID: ownerID,
SharedByID: nil,
ExpiresAt: nil,
CachedAt: now,
UpdatedAt: row.UpdatedAt.UnixMilli(),
})
}
return perms, nil
}
+8 -2
View File
@@ -93,7 +93,13 @@ type OcrJob = { id: string; status: OcrJobStatus; text?: string | null; error?:
- **Idempotence** : contrainte d'unicité serveur `(device_id, operation_id)`. Pour chaque op : si déjà traitée → **no-op** (comptée comme appliquée, les doublons arrivent à cause du backoff/retry). Sinon appliquée si valide.
- **Ordre** : les opérations sont appliquées **séquentiellement**, dans l'ordre du batch. Le serveur **s'arrête à la première erreur non-idempotente** et renvoie l'index atteint — le client reprend à cet index.
- Réponse : `2xx` avec `{ "applied": int, "failed": { "operation_id": int, "code": string, "message": string } | null }` (`applied` = index de la prochaine op à envoyer).
- Côté client, le `pushStatus` (pending/synced/failed) des shares/share_links est **dérivé** de l'état des opérations de l'outbox ; dead-letter après `MAX_PENDING_ATTEMPTS` (= 5).
- Côté client, le `pushStatus` (pending/synced/failed) des shares/share_links est **dérivé** de l'état des opérations de l'outbox ; dead-letter après `MAX_PENDING_ATTEMPTS` (= 5). **Côté serveur, les ops `share | revoke_share | update_share | create_link | revoke_link` sont accusées réception mais ne créent aucun état** (V1 single-owner, pas de table shares serveur) — la dérivation du pushStatus reste purement client.
- Sémantique d'application (côté serveur) :
- `create_resource` : crée la ressource ; **déjà présente → no-op** (rejeu idempotent). `payload.name` obligatoire.
- `update_metadata` / `move_resource` : ressource absente → **no-op** (état terminal atteint) ; dossier cible de `move_resource` absent → `NOT_FOUND` ; déplacement dans soi-même → `INVALID_REQUEST`.
- `delete_resource` : **idempotent** — suppression d'une ressource absente = succès.
- Validation (deuxième champ `operation_id`, hex32 pour `resource_id`, enum `operation`) → échec `INVALID_REQUEST` avec arrêt du batch.
- Nom déjà pris (même parent, ou à la racine) → échec `NAME_CONFLICT`.**
### 6.2 Snapshot — `GET /sync/permissions?after=<cached_at_ms>`
@@ -127,4 +133,4 @@ type ResourcePermission = {
## 7. Codes d'erreur courants
`NOT_FOUND`, `NOT_IMPLEMENTED` (501 temporaire sur les routes non construites — état actuel : files CRUD/upload/search, folders, devices, health sont réels ; `ocr/*`, `sync/*` en queue), `FILE_TOO_LARGE` (413), `NAME_CONFLICT` (409 — même nom dans le même parent, cf. `UNIQUE(parent_id, name)`), `NETWORK_ERROR` (côté client), `HTTP_<status>` (fallback). Le serveur doit répondre 501 `{ "error": { "code": "NOT_IMPLEMENTED", "message": "…" } }` sur toute route encore en queue. Statut `SERVICE_UNAVAILABLE` (503) si le backend n'est pas initialisé.
`NOT_FOUND`, `NOT_IMPLEMENTED` (501 temporaire sur les routes non construites — état actuel : files CRUD/upload/search, folders, devices, health, **sync/ops + sync/permissions** sont réels ; `ocr/*` en queue), `FILE_TOO_LARGE` (413), `NAME_CONFLICT` (409 — même nom dans le même parent, cf. `UNIQUE(parent_id, name)`, **ou à la racine**, index partiel `(owner_id, name) WHERE parent_id IS NULL`), `NETWORK_ERROR` (côté client), `HTTP_<status>` (fallback). Le serveur doit répondre 501 `{ "error": { "code": "NOT_IMPLEMENTED", "message": "…" } }` sur toute route encore en queue. Statut `SERVICE_UNAVAILABLE` (503) si le backend n'est pas initialisé.