feat(backend): migrations Postgres testées (users, devices, resources, operations, ocr_jobs) + migrate au boot

- golang-migrate embarqué via embed (db/migrations/*.sql), MigrateDatabase(url) au boot
- ids TEXT 32-hex avec CHECK ~ '^[0-9a-f]{32}$' ; operations idempotentes UNIQUE(device_id, operation_id)
- harness db/migrations_test.go (up → assertions schéma → down, skip si PG indisponible)
- DB dev fraîche vaultdrop_dev (la DB vaultdrop héberge un prototype V2 abandonné)
This commit is contained in:
m
2026-09-10 07:35:36 +02:00
parent 293c1ca9b2
commit 484498d989
18 changed files with 329 additions and 10 deletions
+80
View File
@@ -0,0 +1,80 @@
package db
import (
"database/sql"
"embed"
"errors"
"fmt"
"github.com/golang-migrate/migrate/v4"
migratepostgres "github.com/golang-migrate/migrate/v4/database/postgres"
"github.com/golang-migrate/migrate/v4/source/iofs"
_ "github.com/lib/pq"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
// Open opens a raw *sql.DB (Postgres). Callers must Close it.
func Open(databaseURL string) (*sql.DB, error) {
conn, err := sql.Open("postgres", databaseURL)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
if err := conn.Ping(); err != nil {
conn.Close()
return nil, fmt.Errorf("ping database: %w", err)
}
return conn, nil
}
func newMigrator(conn *sql.DB) (*migrate.Migrate, error) {
source, err := iofs.New(migrationsFS, "migrations")
if err != nil {
return nil, fmt.Errorf("load embedded migrations: %w", err)
}
driver, err := migratepostgres.WithInstance(conn, &migratepostgres.Config{})
if err != nil {
return nil, fmt.Errorf("init postgres migrate driver: %w", err)
}
m, err := migrate.NewWithInstance("iofs", source, "postgres", driver)
if err != nil {
return nil, fmt.Errorf("new migrate: %w", err)
}
return m, nil
}
// MigrateDatabase applies all pending migrations up to the latest version.
// migrate.ErrNoChange (already up-to-date) is not an error.
func MigrateDatabase(databaseURL string) error {
conn, err := Open(databaseURL)
if err != nil {
return err
}
defer conn.Close()
m, err := newMigrator(conn)
if err != nil {
return err
}
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return err
}
return nil
}
// MigrateDownDatabase rolls back every migration (used by tests).
func MigrateDownDatabase(databaseURL string) error {
conn, err := Open(databaseURL)
if err != nil {
return err
}
defer conn.Close()
m, err := newMigrator(conn)
if err != nil {
return err
}
if err := m.Down(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return err
}
return nil
}
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_users_parent;
DROP INDEX IF EXISTS idx_users_email;
DROP TABLE IF EXISTS users;
@@ -0,0 +1,13 @@
CREATE TABLE users (
id TEXT PRIMARY KEY CHECK (id ~ '^[0-9a-f]{32}$'),
email VARCHAR(255),
display_name VARCHAR(255),
parent_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
storage_quota_bytes BIGINT DEFAULT 10737418240,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX idx_users_email ON users(email) WHERE email IS NOT NULL AND deleted_at IS NULL;
CREATE INDEX idx_users_parent ON users(parent_user_id) WHERE deleted_at IS NULL;
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_devices_user;
DROP TABLE IF EXISTS devices;
@@ -0,0 +1,9 @@
CREATE TABLE devices (
device_id TEXT PRIMARY KEY CHECK (device_id ~ '^[0-9a-f]{32}$'),
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
registered_at TIMESTAMPTZ DEFAULT NOW(),
last_seen_at TIMESTAMPTZ
);
CREATE INDEX idx_devices_user ON devices(user_id) WHERE user_id IS NOT NULL;
@@ -0,0 +1,4 @@
DROP INDEX IF EXISTS idx_resources_category;
DROP INDEX IF EXISTS idx_resources_parent;
DROP INDEX IF EXISTS idx_resources_owner;
DROP TABLE IF EXISTS resources;
@@ -0,0 +1,21 @@
CREATE TABLE resources (
resource_id TEXT PRIMARY KEY CHECK (resource_id ~ '^[0-9a-f]{32}$'),
type TEXT NOT NULL CHECK (type IN ('file', 'folder')),
name TEXT NOT NULL,
parent_id TEXT REFERENCES resources(resource_id) ON DELETE CASCADE,
owner_id TEXT NOT NULL REFERENCES devices(device_id) ON DELETE CASCADE,
content_hash VARCHAR(128),
size_bytes BIGINT DEFAULT 0,
mime_type VARCHAR(255),
extension VARCHAR(64),
category VARCHAR(50) CHECK (category IN ('photo', 'video', 'document', 'audio', 'other')),
taken_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
CONSTRAINT resources_unique_name_per_parent UNIQUE (parent_id, name)
);
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;
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_operations_device_created;
DROP TABLE IF EXISTS operations;
@@ -0,0 +1,14 @@
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}$'),
op_type TEXT NOT NULL,
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'applied' CHECK (status IN ('applied', 'failed')),
error_code TEXT,
applied_at TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT operations_unique_per_device UNIQUE (device_id, operation_id)
);
CREATE INDEX idx_operations_device_created ON operations(device_id, created_at);
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_ocr_jobs_file;
DROP INDEX IF EXISTS idx_ocr_jobs_status;
DROP TABLE IF EXISTS ocr_jobs;
@@ -0,0 +1,15 @@
CREATE TABLE ocr_jobs (
job_id TEXT PRIMARY KEY CHECK (job_id ~ '^[0-9a-f]{32}$'),
file_id TEXT NOT NULL REFERENCES resources(resource_id) ON DELETE CASCADE,
device_id TEXT NOT NULL REFERENCES devices(device_id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'processing', 'done', 'failed')),
language VARCHAR(64) DEFAULT 'fra+eng',
text TEXT,
error TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ
);
CREATE INDEX idx_ocr_jobs_status ON ocr_jobs(status);
CREATE INDEX idx_ocr_jobs_file ON ocr_jobs(file_id);
+153
View File
@@ -0,0 +1,153 @@
package db
import (
"database/sql"
"os"
"strings"
"testing"
_ "github.com/lib/pq"
)
const defaultTestDatabaseURL = "postgres://vaultdrop:vaultdrop@localhost:5432/vaultdrop_migrations_test?sslmode=disable"
func testDatabaseURL(t *testing.T) string {
t.Helper()
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
url = defaultTestDatabaseURL
}
conn, err := sql.Open("postgres", url)
if err != nil {
t.Fatalf("open: %v", err)
}
defer conn.Close()
if err := conn.Ping(); err != nil {
t.Skipf("postgres indisponible (%v) — lancez `docker compose up postgres -d`", err)
}
return url
}
func resetSchema(t *testing.T, url string) {
t.Helper()
conn, err := sql.Open("postgres", url)
if err != nil {
t.Fatalf("open: %v", err)
}
defer conn.Close()
if _, err := conn.Exec(`DROP SCHEMA public CASCADE; CREATE SCHEMA public;`); err != nil {
t.Fatalf("reset schema: %v", err)
}
}
func tableNames(t *testing.T, conn *sql.DB) map[string]bool {
t.Helper()
rows, err := conn.Query(`
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'`)
if err != nil {
t.Fatalf("list tables: %v", err)
}
defer rows.Close()
names := map[string]bool{}
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
t.Fatalf("scan: %v", err)
}
names[name] = true
}
return names
}
func assertTables(t *testing.T, conn *sql.DB, expected ...string) {
t.Helper()
got := tableNames(t, conn)
for _, table := range expected {
if !got[table] {
t.Errorf("table manquante après migrate up : %s", table)
}
}
}
func assertHexCheck(t *testing.T, conn *sql.DB, table, column string) {
t.Helper()
var def string
err := conn.QueryRow(`
SELECT pg_get_constraintdef(c.oid)
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
WHERE t.relname = $1 AND c.contype = 'c'
AND pg_get_constraintdef(c.oid) LIKE '%' || $2 || '%'`,
table, column).Scan(&def)
if err != nil {
t.Fatalf("contrainte CHECK(hex) manquante sur %s.%s: %v", table, column, err)
}
if !strings.Contains(def, "^[0-9a-f]{32}$") {
t.Errorf("CHECK %s.%s attendu avec pattern 32-hex, obtenu : %s", table, column, def)
}
}
func TestMigrationsUpDown(t *testing.T) {
url := testDatabaseURL(t)
resetSchema(t, url)
if err := MigrateDatabase(url); err != nil {
t.Fatalf("migrate up: %v", err)
}
conn, err := sql.Open("postgres", url)
if err != nil {
t.Fatalf("open: %v", err)
}
defer conn.Close()
if err := conn.Ping(); err != nil {
t.Fatalf("ping: %v", err)
}
assertTables(t, conn, "users", "devices", "resources", "operations", "ocr_jobs", "schema_migrations")
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")
var resourceTypeCheck int
err = conn.QueryRow(`
SELECT COUNT(*) FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
WHERE t.relname = 'resources'
AND pg_get_constraintdef(c.oid) LIKE '%''file''%'
AND pg_get_constraintdef(c.oid) LIKE '%''folder''%'`).Scan(&resourceTypeCheck)
if err != nil {
t.Fatalf("check type resources: %v", err)
}
if resourceTypeCheck == 0 {
t.Error("CHECK (type IN ('file','folder')) manquant sur resources")
}
var opUnique int
err = conn.QueryRow(`
SELECT COUNT(*) FROM pg_index i
JOIN pg_class t ON t.oid = i.indrelid
WHERE t.relname = 'operations' AND i.indisunique
AND ARRAY(SELECT a.attname FROM unnest(i.indkey) WITH ORDINALITY k(attnum, ord)
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum
ORDER BY k.ord)::text[] = ARRAY['device_id','operation_id']`).Scan(&opUnique)
if err != nil {
t.Fatalf("unique operations: %v", err)
}
if opUnique == 0 {
t.Error("contrainte UNIQUE(device_id, operation_id) manquante sur operations")
}
if err := MigrateDownDatabase(url); err != nil {
t.Fatalf("migrate down: %v", err)
}
remaining := tableNames(t, conn)
delete(remaining, "schema_migrations")
if len(remaining) > 0 {
t.Errorf("tables restantes après migrate down : %v", remaining)
}
}