Add database migrations runner

This commit is contained in:
Tiago Yamamoto 2025-12-20 10:32:54 -03:00
parent c3006064f7
commit 77f414bf02
4 changed files with 264 additions and 124 deletions

View 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
}

View 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
);

View file

@ -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
}

View file

@ -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
}