From ddb1964d1ad928c97b7346fd1640373c13b70ecb Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 4 Feb 2026 22:13:03 +0100 Subject: [PATCH] feat(01-02): create Flyway migrator service and initial migration - Add Migrator trait with migrate/status ZIO effects - Create V1 migration for summer_users table - Configure Flyway with summer_migrations table and classpath location - Migrations run manually via CLI, not on startup --- .../db/migration/V1__create_summer_users.sql | 26 ++++++ summercms/src/db/Migrator.scala | 80 +++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 summercms/resources/db/migration/V1__create_summer_users.sql create mode 100644 summercms/src/db/Migrator.scala diff --git a/summercms/resources/db/migration/V1__create_summer_users.sql b/summercms/resources/db/migration/V1__create_summer_users.sql new file mode 100644 index 0000000..968becb --- /dev/null +++ b/summercms/resources/db/migration/V1__create_summer_users.sql @@ -0,0 +1,26 @@ +-- Summer CMS initial schema +-- Creates the base users table for future auth + +CREATE TABLE summer_users ( + id BIGSERIAL PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_summer_users_email ON summer_users(email); + +-- Trigger for updated_at +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER update_summer_users_updated_at + BEFORE UPDATE ON summer_users + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); diff --git a/summercms/src/db/Migrator.scala b/summercms/src/db/Migrator.scala new file mode 100644 index 0000000..4c425d8 --- /dev/null +++ b/summercms/src/db/Migrator.scala @@ -0,0 +1,80 @@ +package db + +import org.flywaydb.core.Flyway +import org.flywaydb.core.api.MigrationInfo +import zio.* +import javax.sql.DataSource + +/** Migration status summary + * + * @param current + * The currently applied migration version (None if no migrations applied) + * @param pending + * Number of pending migrations waiting to be applied + */ +case class MigrationStatus(current: Option[String], pending: Int) + +/** Flyway database migration service + * + * Wraps Flyway migrations in ZIO effects for controlled execution. Migrations are NOT auto-run on + * application startup - they must be explicitly invoked via CLI (Phase 5: CLI Scaffolding). + * + * Migration files are stored in: resources/db/migration/ Following Flyway naming convention: + * V{version}__{description}.sql + * + * Example: V1__create_summer_users.sql + */ +trait Migrator { + + /** Run all pending migrations + * + * @return + * Number of migrations applied + */ + def migrate: Task[Int] + + /** Get current migration status + * + * @return + * Status with current version and pending count + */ + def status: Task[MigrationStatus] +} + +object Migrator { + + /** Live implementation of Migrator backed by Flyway */ + val live: ZLayer[DataSource, Nothing, Migrator] = + ZLayer.fromFunction { (ds: DataSource) => + new Migrator { + private def flyway: Flyway = + Flyway.configure() + .dataSource(ds) + .locations("classpath:db/migration") + .table("summer_migrations") + .baselineOnMigrate(true) + .load() + + override def migrate: Task[Int] = + ZIO.attempt { + flyway.migrate().migrationsExecuted + } + + override def status: Task[MigrationStatus] = + ZIO.attempt { + val info = flyway.info() + val current = Option(info.current()).map(_.getVersion.getVersion) + val pending = info.pending().length + MigrationStatus(current, pending) + } + } + } + + /** Accessor for migrate effect */ + def migrate: ZIO[Migrator, Throwable, Int] = + ZIO.serviceWithZIO[Migrator](_.migrate) + + /** Accessor for status effect */ + def status: ZIO[Migrator, Throwable, MigrationStatus] = + ZIO.serviceWithZIO[Migrator](_.status) +}