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:
parent
26beee7e9c
commit
02cfe769d7
5 changed files with 89 additions and 0 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
59
internal/database/migrations.go
Normal file
59
internal/database/migrations.go
Normal 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
|
||||
}
|
||||
23
migrations/002_recovery.sql
Normal file
23
migrations/002_recovery.sql
Normal 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;
|
||||
Loading…
Reference in a new issue