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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user