Add database migrations runner
This commit is contained in:
parent
c3006064f7
commit
77f414bf02
4 changed files with 264 additions and 124 deletions
152
backend/internal/repository/postgres/migrations.go
Normal file
152
backend/internal/repository/postgres/migrations.go
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationFiles embed.FS
|
||||
|
||||
type migration struct {
|
||||
version int
|
||||
name string
|
||||
sql string
|
||||
}
|
||||
|
||||
// ApplyMigrations ensures the database schema is up to date.
|
||||
func (r *Repository) ApplyMigrations(ctx context.Context) error {
|
||||
migrations, err := loadMigrations()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := r.ensureMigrationsTable(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
applied, err := r.fetchAppliedVersions(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
if applied[m.version] {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := r.applyMigration(ctx, m); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadMigrations() ([]migration, error) {
|
||||
entries, err := migrationFiles.ReadDir("migrations")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read migrations dir: %w", err)
|
||||
}
|
||||
|
||||
migrations := make([]migration, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
name := entry.Name()
|
||||
version, err := parseMigrationVersion(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
content, err := migrationFiles.ReadFile("migrations/" + name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read migration %s: %w", name, err)
|
||||
}
|
||||
|
||||
migrations = append(migrations, migration{
|
||||
version: version,
|
||||
name: name,
|
||||
sql: string(content),
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(migrations, func(i, j int) bool {
|
||||
return migrations[i].version < migrations[j].version
|
||||
})
|
||||
|
||||
return migrations, nil
|
||||
}
|
||||
|
||||
func parseMigrationVersion(name string) (int, error) {
|
||||
var version int
|
||||
if _, err := fmt.Sscanf(name, "%d_", &version); err == nil {
|
||||
return version, nil
|
||||
}
|
||||
if _, err := fmt.Sscanf(name, "%d-", &version); err == nil {
|
||||
return version, nil
|
||||
}
|
||||
return 0, fmt.Errorf("invalid migration filename: %s", name)
|
||||
}
|
||||
|
||||
func (r *Repository) ensureMigrationsTable(ctx context.Context) error {
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TIMESTAMPTZ NOT NULL
|
||||
);`
|
||||
if _, err := r.db.ExecContext(ctx, schema); err != nil {
|
||||
return fmt.Errorf("create migrations table: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) fetchAppliedVersions(ctx context.Context) (map[int]bool, error) {
|
||||
rows, err := r.db.QueryxContext(ctx, `SELECT version FROM schema_migrations`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch applied migrations: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
applied := make(map[int]bool)
|
||||
for rows.Next() {
|
||||
var version int
|
||||
if err := rows.Scan(&version); err != nil {
|
||||
return nil, fmt.Errorf("scan applied migration: %w", err)
|
||||
}
|
||||
applied[version] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("read applied migrations: %w", err)
|
||||
}
|
||||
return applied, nil
|
||||
}
|
||||
|
||||
func (r *Repository) applyMigration(ctx context.Context, m migration) error {
|
||||
tx, err := r.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin migration %d: %w", m.version, err)
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, m.sql); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("apply migration %d: %w", m.version, err)
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations (version, name, applied_at) VALUES ($1, $2, $3)`, m.version, m.name, time.Now().UTC()); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("record migration %d: %w", m.version, err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit migration %d: %w", m.version, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
111
backend/internal/repository/postgres/migrations/0001_init.sql
Normal file
111
backend/internal/repository/postgres/migrations/0001_init.sql
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
CREATE TABLE IF NOT EXISTS companies (
|
||||
id UUID PRIMARY KEY,
|
||||
cnpj TEXT NOT NULL UNIQUE,
|
||||
corporate_name TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'farmacia',
|
||||
license_number TEXT NOT NULL,
|
||||
is_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
latitude DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
longitude DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
city TEXT NOT NULL DEFAULT '',
|
||||
state TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY,
|
||||
company_id UUID NOT NULL REFERENCES companies(id),
|
||||
role TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
id UUID PRIMARY KEY,
|
||||
seller_id UUID NOT NULL REFERENCES companies(id),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
batch TEXT NOT NULL,
|
||||
expires_at DATE NOT NULL,
|
||||
price_cents BIGINT NOT NULL,
|
||||
stock BIGINT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inventory_adjustments (
|
||||
id UUID PRIMARY KEY,
|
||||
product_id UUID NOT NULL REFERENCES products(id),
|
||||
delta BIGINT NOT NULL,
|
||||
reason TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id UUID PRIMARY KEY,
|
||||
buyer_id UUID NOT NULL REFERENCES companies(id),
|
||||
seller_id UUID NOT NULL REFERENCES companies(id),
|
||||
status TEXT NOT NULL,
|
||||
total_cents BIGINT NOT NULL,
|
||||
shipping_recipient_name TEXT,
|
||||
shipping_street TEXT,
|
||||
shipping_number TEXT,
|
||||
shipping_complement TEXT,
|
||||
shipping_district TEXT,
|
||||
shipping_city TEXT,
|
||||
shipping_state TEXT,
|
||||
shipping_zip_code TEXT,
|
||||
shipping_country TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS order_items (
|
||||
id UUID PRIMARY KEY,
|
||||
order_id UUID NOT NULL REFERENCES orders(id),
|
||||
product_id UUID NOT NULL REFERENCES products(id),
|
||||
quantity BIGINT NOT NULL,
|
||||
unit_cents BIGINT NOT NULL,
|
||||
batch TEXT NOT NULL,
|
||||
expires_at DATE NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cart_items (
|
||||
id UUID PRIMARY KEY,
|
||||
buyer_id UUID NOT NULL REFERENCES companies(id),
|
||||
product_id UUID NOT NULL REFERENCES products(id),
|
||||
quantity BIGINT NOT NULL,
|
||||
unit_cents BIGINT NOT NULL,
|
||||
batch TEXT,
|
||||
expires_at DATE,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL,
|
||||
UNIQUE (buyer_id, product_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reviews (
|
||||
id UUID PRIMARY KEY,
|
||||
order_id UUID NOT NULL UNIQUE REFERENCES orders(id),
|
||||
buyer_id UUID NOT NULL REFERENCES companies(id),
|
||||
seller_id UUID NOT NULL REFERENCES companies(id),
|
||||
rating INT NOT NULL CHECK (rating BETWEEN 1 AND 5),
|
||||
comment TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_reviews_seller_id ON reviews (seller_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS shipments (
|
||||
id UUID PRIMARY KEY,
|
||||
order_id UUID NOT NULL UNIQUE REFERENCES orders(id),
|
||||
carrier TEXT NOT NULL,
|
||||
tracking_code TEXT,
|
||||
external_tracking TEXT,
|
||||
status TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
|
@ -952,126 +952,3 @@ func (r *Repository) AdminDashboard(ctx context.Context, since time.Time) (*doma
|
|||
|
||||
return &domain.AdminDashboard{GMVCents: totalGMV, NewCompanies: newCompanies, WindowStartAt: since}, nil
|
||||
}
|
||||
|
||||
// InitSchema applies a minimal schema for development environments.
|
||||
func (r *Repository) InitSchema(ctx context.Context) error {
|
||||
schema := `
|
||||
CREATE TABLE IF NOT EXISTS companies (
|
||||
id UUID PRIMARY KEY,
|
||||
cnpj TEXT NOT NULL UNIQUE,
|
||||
corporate_name TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'farmacia',
|
||||
license_number TEXT NOT NULL,
|
||||
is_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
latitude DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
longitude DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
city TEXT NOT NULL DEFAULT '',
|
||||
state TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY,
|
||||
company_id UUID NOT NULL REFERENCES companies(id),
|
||||
role TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
id UUID PRIMARY KEY,
|
||||
seller_id UUID NOT NULL REFERENCES companies(id),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
batch TEXT NOT NULL,
|
||||
expires_at DATE NOT NULL,
|
||||
price_cents BIGINT NOT NULL,
|
||||
stock BIGINT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inventory_adjustments (
|
||||
id UUID PRIMARY KEY,
|
||||
product_id UUID NOT NULL REFERENCES products(id),
|
||||
delta BIGINT NOT NULL,
|
||||
reason TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id UUID PRIMARY KEY,
|
||||
buyer_id UUID NOT NULL REFERENCES companies(id),
|
||||
seller_id UUID NOT NULL REFERENCES companies(id),
|
||||
status TEXT NOT NULL,
|
||||
total_cents BIGINT NOT NULL,
|
||||
shipping_recipient_name TEXT,
|
||||
shipping_street TEXT,
|
||||
shipping_number TEXT,
|
||||
shipping_complement TEXT,
|
||||
shipping_district TEXT,
|
||||
shipping_city TEXT,
|
||||
shipping_state TEXT,
|
||||
shipping_zip_code TEXT,
|
||||
shipping_country TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS order_items (
|
||||
id UUID PRIMARY KEY,
|
||||
order_id UUID NOT NULL REFERENCES orders(id),
|
||||
product_id UUID NOT NULL REFERENCES products(id),
|
||||
quantity BIGINT NOT NULL,
|
||||
unit_cents BIGINT NOT NULL,
|
||||
batch TEXT NOT NULL,
|
||||
expires_at DATE NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cart_items (
|
||||
id UUID PRIMARY KEY,
|
||||
buyer_id UUID NOT NULL REFERENCES companies(id),
|
||||
product_id UUID NOT NULL REFERENCES products(id),
|
||||
quantity BIGINT NOT NULL,
|
||||
unit_cents BIGINT NOT NULL,
|
||||
batch TEXT,
|
||||
expires_at DATE,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL,
|
||||
UNIQUE (buyer_id, product_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reviews (
|
||||
id UUID PRIMARY KEY,
|
||||
order_id UUID NOT NULL UNIQUE REFERENCES orders(id),
|
||||
buyer_id UUID NOT NULL REFERENCES companies(id),
|
||||
seller_id UUID NOT NULL REFERENCES companies(id),
|
||||
rating INT NOT NULL CHECK (rating BETWEEN 1 AND 5),
|
||||
comment TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_reviews_seller_id ON reviews (seller_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS shipments (
|
||||
id UUID PRIMARY KEY,
|
||||
order_id UUID NOT NULL UNIQUE REFERENCES orders(id),
|
||||
carrier TEXT NOT NULL,
|
||||
tracking_code TEXT,
|
||||
external_tracking TEXT,
|
||||
status TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
`
|
||||
|
||||
if _, err := r.db.ExecContext(ctx, schema); err != nil {
|
||||
return fmt.Errorf("apply schema: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ func (s *Server) Start(ctx context.Context) error {
|
|||
}
|
||||
|
||||
repo := postgres.New(s.db)
|
||||
if err := repo.InitSchema(ctx); err != nil {
|
||||
if err := repo.ApplyMigrations(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue