From 821f65ff3e69ca5145da6c3faea08ba7d17df249 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 19 Nov 2025 00:52:26 -0500 Subject: [PATCH] feat: implement timezone-aware timestamps for projections Signed-off-by: Yordis Prieto --- guides/explanations/fork-differences.md | 46 +++++++++++++++ .../howtos/building-read-models-with-ecto.md | 13 ++--- .../ecto-projections-getting-started.md | 13 +++-- lib/commanded/projections/ecto.ex | 2 +- .../v01_create_projection_versions_table.ex | 54 ++++++++++++++++++ .../v02_upgrade_timestamps_to_timezone.ex | 56 +++++++++++++++++++ ...70609113553_create_projection_versions.exs | 13 +++-- ..._create_projection_version_with_prefix.exs | 13 ++--- test/test_helper.exs | 2 +- 9 files changed, 180 insertions(+), 32 deletions(-) create mode 100644 lib/commanded/projections/ecto/migrations/v01_create_projection_versions_table.ex create mode 100644 lib/commanded/projections/ecto/migrations/v02_upgrade_timestamps_to_timezone.ex diff --git a/guides/explanations/fork-differences.md b/guides/explanations/fork-differences.md index f88c0634..dd8a4819 100644 --- a/guides/explanations/fork-differences.md +++ b/guides/explanations/fork-differences.md @@ -100,3 +100,49 @@ This fork maintains an independent release cycle to introduce new features and i - Update dependency to `{:commanded, "~> 2.1"}` with `{:ecto, "~> 3.11"}` - Update config from `config :commanded_ecto_projections, repo: MyApp.Repo` to `config :commanded, Commanded.Projections.Ecto, repo: MyApp.Repo` - No code changes required - API remains the same + +### **Timezone-Aware Timestamps for Projections** + +**Changes:** +- `projection_versions` table now uses `timestamp with time zone` for `inserted_at` and `updated_at` columns +- ProjectionVersion schema updated to use `:utc_datetime_usec` instead of `:naive_datetime_usec` +- Added migration helper modules for both new and existing installations + +**Benefits:** +- Ensures timestamp values are stored with timezone information +- Better consistency across different database configurations +- Prevents timezone-related issues when working with distributed systems + +**For new installations (greenfield):** +```elixir +defmodule MyApp.Repo.Migrations.CreateProjectionVersionsTable do + alias Commanded.Projections.Ecto.Migrations.V01CreateProjectionVersionsTable + + use Ecto.Migration + + def up do + V01CreateProjectionVersionsTable.up() + end + + def down do + V01CreateProjectionVersionsTable.down() + end +end +``` + +**For existing installations (upgrade):** +```elixir +defmodule MyApp.Repo.Migrations.UpgradeProjectionVersionsTimestamps do + alias Commanded.Projections.Ecto.Migrations.V02UpgradeTimestampsToTimezone + + use Ecto.Migration + + def up do + V02UpgradeTimestampsToTimezone.up() + end + + def down do + V02UpgradeTimestampsToTimezone.down() + end +end +``` diff --git a/guides/howtos/building-read-models-with-ecto.md b/guides/howtos/building-read-models-with-ecto.md index 364d998b..6f62e5a5 100644 --- a/guides/howtos/building-read-models-with-ecto.md +++ b/guides/howtos/building-read-models-with-ecto.md @@ -453,22 +453,17 @@ When using a prefix for your Ecto schemas you might also want to change the pref ```elixir defmodule CreateSchemaProjectionVersions do + alias Commanded.Projections.Ecto.Migrations.V01CreateProjectionVersionsTable + use Ecto.Migration def up do execute("CREATE SCHEMA example_schema_prefix") - - create table(:projection_versions, primary_key: false, prefix: "example_schema_prefix") do - add(:projection_name, :text, primary_key: true) - add(:last_seen_event_number, :bigint) - - timestamps(type: :naive_datetime_usec) - end + V01CreateProjectionVersionsTable.up(prefix: "example_schema_prefix") end def down do - drop(table(:projection_versions, prefix: "example_schema_prefix")) - + V01CreateProjectionVersionsTable.down(prefix: "example_schema_prefix") execute("DROP SCHEMA example_schema_prefix CASCADE") end end diff --git a/guides/howtos/ecto-projections-getting-started.md b/guides/howtos/ecto-projections-getting-started.md index 55bef740..8aa25717 100644 --- a/guides/howtos/ecto-projections-getting-started.md +++ b/guides/howtos/ecto-projections-getting-started.md @@ -23,15 +23,16 @@ Commanded includes built-in support for read model projections using Ecto. You s ```elixir defmodule CreateProjectionVersions do + alias Commanded.Projections.Ecto.Migrations.V01CreateProjectionVersionsTable + use Ecto.Migration - def change do - create table(:projection_versions, primary_key: false) do - add(:projection_name, :text, primary_key: true) - add(:last_seen_event_number, :bigint) + def up do + V01CreateProjectionVersionsTable.up() + end - timestamps(type: :naive_datetime_usec) - end + def down do + V01CreateProjectionVersionsTable.down() end end ``` diff --git a/lib/commanded/projections/ecto.ex b/lib/commanded/projections/ecto.ex index 6224e4d2..eed81c70 100644 --- a/lib/commanded/projections/ecto.ex +++ b/lib/commanded/projections/ecto.ex @@ -574,7 +574,7 @@ if Code.ensure_loaded?(Ecto) do schema "projection_versions" do field(:last_seen_event_number, :integer) - timestamps(type: :naive_datetime_usec) + timestamps(type: :utc_datetime_usec) end end end diff --git a/lib/commanded/projections/ecto/migrations/v01_create_projection_versions_table.ex b/lib/commanded/projections/ecto/migrations/v01_create_projection_versions_table.ex new file mode 100644 index 00000000..427796c4 --- /dev/null +++ b/lib/commanded/projections/ecto/migrations/v01_create_projection_versions_table.ex @@ -0,0 +1,54 @@ +if Code.ensure_loaded?(Ecto.Migration) do + defmodule Commanded.Projections.Ecto.Migrations.V01CreateProjectionVersionsTable do + @moduledoc """ + Version 1: Creates the `projection_versions` table with timezone-aware timestamps. + + Use this migration for **new installations** (greenfield projects). + + ## Usage + + defmodule MyApp.Repo.Migrations.CreateProjectionVersionsTable do + use Ecto.Migration + + def up do + Commanded.Projections.Ecto.Migrations.V01CreateProjectionVersionsTable.up() + end + + def down do + Commanded.Projections.Ecto.Migrations.V01CreateProjectionVersionsTable.down() + end + end + + ## Options + + * `:prefix` - The database schema prefix. Defaults to `nil` (public schema). + + ## Examples + + # Default schema (public) + Commanded.Projections.Ecto.Migrations.V01CreateProjectionVersionsTable.up() + + # Custom schema + Commanded.Projections.Ecto.Migrations.V01CreateProjectionVersionsTable.up(prefix: "projections") + + """ + + use Ecto.Migration + + def up(opts \\ []) do + prefix = Keyword.get(opts, :prefix) + + create table(:projection_versions, primary_key: false, prefix: prefix) do + add(:projection_name, :text, primary_key: true) + add(:last_seen_event_number, :bigint) + + timestamps(type: :timestamptz) + end + end + + def down(opts \\ []) do + prefix = Keyword.get(opts, :prefix) + drop(table(:projection_versions, prefix: prefix)) + end + end +end diff --git a/lib/commanded/projections/ecto/migrations/v02_upgrade_timestamps_to_timezone.ex b/lib/commanded/projections/ecto/migrations/v02_upgrade_timestamps_to_timezone.ex new file mode 100644 index 00000000..9b3497e0 --- /dev/null +++ b/lib/commanded/projections/ecto/migrations/v02_upgrade_timestamps_to_timezone.ex @@ -0,0 +1,56 @@ +if Code.ensure_loaded?(Ecto.Migration) do + defmodule Commanded.Projections.Ecto.Migrations.V02UpgradeTimestampsToTimezone do + @moduledoc """ + Version 2: Upgrades timestamps to timezone-aware types. + + Use this migration for **existing installations** that have the old schema without timezone-aware timestamps. + + ## Usage + + defmodule MyApp.Repo.Migrations.UpgradeProjectionVersionsTimestamps do + use Ecto.Migration + + def up do + Commanded.Projections.Ecto.Migrations.V02UpgradeTimestampsToTimezone.up() + end + + def down do + Commanded.Projections.Ecto.Migrations.V02UpgradeTimestampsToTimezone.down() + end + end + + ## Options + + * `:prefix` - The database schema prefix. Defaults to `nil` (public schema). + + ## Examples + + # Default schema (public) + Commanded.Projections.Ecto.Migrations.V02UpgradeTimestampsToTimezone.up() + + # Custom schema + Commanded.Projections.Ecto.Migrations.V02UpgradeTimestampsToTimezone.up(prefix: "projections") + + """ + + use Ecto.Migration + + def up(opts \\ []) do + prefix = Keyword.get(opts, :prefix) + + alter table(:projection_versions, prefix: prefix) do + modify(:inserted_at, :timestamptz, from: :naive_datetime_usec) + modify(:updated_at, :timestamptz, from: :naive_datetime_usec) + end + end + + def down(opts \\ []) do + prefix = Keyword.get(opts, :prefix) + + alter table(:projection_versions, prefix: prefix) do + modify(:inserted_at, :naive_datetime_usec, from: :timestamptz) + modify(:updated_at, :naive_datetime_usec, from: :timestamptz) + end + end + end +end diff --git a/priv/repo/migrations/20170609113553_create_projection_versions.exs b/priv/repo/migrations/20170609113553_create_projection_versions.exs index 328e258c..d06259f0 100644 --- a/priv/repo/migrations/20170609113553_create_projection_versions.exs +++ b/priv/repo/migrations/20170609113553_create_projection_versions.exs @@ -1,12 +1,13 @@ defmodule Commanded.Projections.Repo.Migrations.CreateProjectionVersions do + alias Commanded.Projections.Ecto.Migrations.V01CreateProjectionVersionsTable + use Ecto.Migration - def change do - create table(:projection_versions, primary_key: false) do - add(:projection_name, :text, primary_key: true) - add(:last_seen_event_number, :bigint) + def up do + V01CreateProjectionVersionsTable.up() + end - timestamps(type: :naive_datetime_usec) - end + def down do + V01CreateProjectionVersionsTable.down() end end diff --git a/priv/repo/migrations/20170929080308_create_projection_version_with_prefix.exs b/priv/repo/migrations/20170929080308_create_projection_version_with_prefix.exs index da2e2a38..0049b8f4 100644 --- a/priv/repo/migrations/20170929080308_create_projection_version_with_prefix.exs +++ b/priv/repo/migrations/20170929080308_create_projection_version_with_prefix.exs @@ -1,20 +1,15 @@ defmodule Commanded.Projections.Repo.Migrations.CreateProjectionVersionWithPrefix do + alias Commanded.Projections.Ecto.Migrations.V01CreateProjectionVersionsTable + use Ecto.Migration def up do execute("CREATE SCHEMA test") - - create table(:projection_versions, primary_key: false, prefix: "test") do - add(:projection_name, :text, primary_key: true) - add(:last_seen_event_number, :bigint) - - timestamps(type: :naive_datetime_usec) - end + V01CreateProjectionVersionsTable.up(prefix: "test") end def down do - drop(table(:projection_versions, prefix: "test")) - + V01CreateProjectionVersionsTable.down(prefix: "test") execute("DROP SCHEMA test CASCADE") end end diff --git a/test/test_helper.exs b/test/test_helper.exs index 60529a86..e1741212 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -12,7 +12,7 @@ defmodule CreateProjections do end end -Ecto.Migrator.up(Repo, 20_170_609_120_000, CreateProjections) +Ecto.Migrator.up(Repo, 20_171_001_000_000, CreateProjections) ExUnit.start()