feat: add versioned migrations and recovery schema

Apply ordered database migrations at startup and add recovery and conflict persistence tables.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Murilo Scolari 2026-10-21 20:48:17 -03:00
parent 26beee7e9c
commit 02cfe769d7
5 changed files with 89 additions and 0 deletions

View file

@ -9,5 +9,6 @@ FROM alpine:3.20
RUN adduser -D -H -u 10001 app
USER app
COPY --from=build /out/simpsync /simpsync
COPY --from=build /src/migrations /migrations
EXPOSE 8080
ENTRYPOINT ["/simpsync"]

View file

@ -34,6 +34,9 @@ Without PostgreSQL, run the unit tests:
go test ./...
```
The server applies ordered files from `migrations/` on startup and records
completed files in `schema_migration`. Keep migration files append-only.
The current MVP intentionally does not implement password recovery, conflict
merging, or device listing/revocation endpoints yet. Those are required before
production deployment.

View file

@ -24,6 +24,9 @@ func main() {
log.Fatalf("database: %v", err)
}
defer store.Pool.Close()
if err := store.Migrate(ctx, "migrations"); err != nil {
log.Fatalf("migrations: %v", err)
}
server := &http.Server{
Addr: ":" + cfg.Port,
Handler: (&handlers.Handler{Store: store, Config: cfg, Hub: syncws.NewHub()}).Routes(),

View file

@ -0,0 +1,59 @@
package database
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
func (s *Store) Migrate(ctx context.Context, directory string) error {
entries, err := os.ReadDir(directory)
if err != nil {
return fmt.Errorf("read migrations: %w", err)
}
var files []string
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
files = append(files, entry.Name())
}
}
sort.Strings(files)
if _, err := s.Pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS schema_migration (
version TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`); err != nil {
return fmt.Errorf("create migration table: %w", err)
}
for _, name := range files {
var applied bool
if err := s.Pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM schema_migration WHERE version=$1)`, name).Scan(&applied); err != nil {
return fmt.Errorf("check migration %s: %w", name, err)
}
if applied {
continue
}
sql, err := os.ReadFile(filepath.Join(directory, name))
if err != nil {
return fmt.Errorf("read migration %s: %w", name, err)
}
tx, err := s.Pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin migration %s: %w", name, err)
}
if _, err = tx.Exec(ctx, string(sql)); err == nil {
_, err = tx.Exec(ctx, `INSERT INTO schema_migration(version) VALUES($1)`, name)
}
if err != nil {
_ = tx.Rollback(ctx)
return fmt.Errorf("apply migration %s: %w", name, err)
}
if err = tx.Commit(ctx); err != nil {
return fmt.Errorf("commit migration %s: %w", name, err)
}
}
return nil
}

View file

@ -0,0 +1,23 @@
CREATE TABLE IF NOT EXISTS account_recovery (
user_id UUID PRIMARY KEY REFERENCES app_user(id) ON DELETE CASCADE,
recovery_key_hash BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
rotated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS sync_conflict (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES app_user(id) ON DELETE CASCADE,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
local_envelope BYTEA NOT NULL,
remote_envelope BYTEA NOT NULL,
local_revision BIGINT NOT NULL,
remote_revision BIGINT NOT NULL,
resolved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS sync_conflict_pending
ON sync_conflict (user_id, resolved_at)
WHERE resolved_at IS NULL;