From 6eab7aea653975f399b2482981d2081a8466295c Mon Sep 17 00:00:00 2001
From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com>
Date: Tue, 6 Oct 2026 07:31:24 +0000
Subject: [PATCH 1/8] [Mocha] Fix PostgreSQL transport startup with restricted
database users
Add explicit transport schema migration, offline SQL export, and an AutoMigrate switch that skips schema work when disabled. Preserve existing topology provisioning and migration history compatibility.
Cover restricted runtime users, migration concurrency and rollback, SQL artifact execution, and EF outbox persistence with fresh and template databases. Document setup and export commands.
---
.../PostgresTransportConfiguration.cs | 6 +
.../Connection/PostgresConnectionManager.cs | 3 +-
.../Connection/PostgresMigrationSql.cs | 70 ++++
.../Connection/PostgresSchemaMigrator.cs | 140 +++----
.../Connection/PostgresSchemaSql.cs | 80 ++--
.../IPostgresMessagingTransportDescriptor.cs | 6 +
.../PostgresMessagingTransportDescriptor.cs | 7 +
.../Descriptors/PostgresQueueDescriptor.cs | 2 +-
.../PostgresMessagingTransport.cs | 13 +-
.../PostgresTransportSchema.cs | 70 ++++
.../Mocha.Transport.Postgres/ThrowHelper.cs | 13 +
....EntityFrameworkCore.Postgres.Tests.csproj | 1 +
.../PostgresMigrationOutboxTests.cs | 89 +++++
.../Behaviors/AutoMigrateIntegrationTests.cs | 228 +++++++++++
.../AutoProvisionIntegrationTests.cs | 6 +-
.../PostgresMigrationScriptTests.cs | 39 ++
.../PostgresTransportSchemaTests.cs | 359 ++++++++++++++++++
...rnDeterministicSql_When_UsingDefaults.snap | 118 ++++++
...eTransportSchema_When_DatabaseIsEmpty.snap | 85 +++++
.../Descriptors/PostgresDescriptorTests.cs | 38 ++
.../Helpers/PostgresFixture.cs | 19 +-
.../content/docs/mocha/transports/postgres.md | 157 +++++++-
22 files changed, 1395 insertions(+), 154 deletions(-)
create mode 100644 src/Mocha/src/Mocha.Transport.Postgres/Connection/PostgresMigrationSql.cs
create mode 100644 src/Mocha/src/Mocha.Transport.Postgres/PostgresTransportSchema.cs
create mode 100644 src/Mocha/src/Mocha.Transport.Postgres/ThrowHelper.cs
create mode 100644 src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/PostgresMigrationOutboxTests.cs
create mode 100644 src/Mocha/test/Mocha.Transport.Postgres.Tests/Behaviors/AutoMigrateIntegrationTests.cs
create mode 100644 src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/PostgresMigrationScriptTests.cs
create mode 100644 src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/PostgresTransportSchemaTests.cs
create mode 100644 src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/__snapshots__/PostgresMigrationScriptTests.GenerateMigrationScript_Should_ReturnDeterministicSql_When_UsingDefaults.snap
create mode 100644 src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/__snapshots__/PostgresTransportSchemaTests.MigrateAsync_Should_CreateTransportSchema_When_DatabaseIsEmpty.snap
diff --git a/src/Mocha/src/Mocha.Transport.Postgres/Configurations/PostgresTransportConfiguration.cs b/src/Mocha/src/Mocha.Transport.Postgres/Configurations/PostgresTransportConfiguration.cs
index 3436df9b042..87e1bddab3a 100644
--- a/src/Mocha/src/Mocha.Transport.Postgres/Configurations/PostgresTransportConfiguration.cs
+++ b/src/Mocha/src/Mocha.Transport.Postgres/Configurations/PostgresTransportConfiguration.cs
@@ -59,6 +59,12 @@ public PostgresTransportConfiguration()
///
public bool? AutoProvision { get; set; }
+ ///
+ /// Gets or sets whether transport schema migrations run at startup. Defaults to true.
+ /// When false, startup skips migration without checking the schema.
+ ///
+ public bool AutoMigrate { get; set; } = true;
+
///
/// Gets or sets the bus-level defaults applied to all auto-provisioned queues and topics.
///
diff --git a/src/Mocha/src/Mocha.Transport.Postgres/Connection/PostgresConnectionManager.cs b/src/Mocha/src/Mocha.Transport.Postgres/Connection/PostgresConnectionManager.cs
index 8a3cfda41c6..f37afa904c3 100644
--- a/src/Mocha/src/Mocha.Transport.Postgres/Connection/PostgresConnectionManager.cs
+++ b/src/Mocha/src/Mocha.Transport.Postgres/Connection/PostgresConnectionManager.cs
@@ -50,8 +50,7 @@ public async Task EnsureMigratedAsync(CancellationToken cancellationToken)
}
await using var connection = await OpenConnectionAsync(cancellationToken);
- var migrator = new PostgresSchemaMigrator(_schemaOptions);
- await migrator.MigrateAsync(connection);
+ await PostgresTransportSchema.MigrateAsync(connection, _schemaOptions, cancellationToken);
_isMigrated = true;
_logger.SchemaMigrated();
diff --git a/src/Mocha/src/Mocha.Transport.Postgres/Connection/PostgresMigrationSql.cs b/src/Mocha/src/Mocha.Transport.Postgres/Connection/PostgresMigrationSql.cs
new file mode 100644
index 00000000000..982704cc65f
--- /dev/null
+++ b/src/Mocha/src/Mocha.Transport.Postgres/Connection/PostgresMigrationSql.cs
@@ -0,0 +1,70 @@
+namespace Mocha.Transport.Postgres;
+
+internal static class PostgresMigrationSql
+{
+ public static string Literal(string value)
+ => "E'" + value.Replace("\\", "\\\\").Replace("'", "''") + "'";
+
+ public static string Identifier(string identifier)
+ {
+ if (string.IsNullOrEmpty(identifier) || identifier.Any(char.IsControl))
+ {
+ throw ThrowHelper.InvalidSqlIdentifier(identifier);
+ }
+
+ if (identifier[0] == '"')
+ {
+ if (identifier.Length < 3 || identifier[^1] != '"')
+ {
+ throw ThrowHelper.InvalidSqlIdentifier(identifier);
+ }
+
+ for (var i = 1; i < identifier.Length - 1; i++)
+ {
+ if (identifier[i] == '"' && (++i >= identifier.Length - 1 || identifier[i] != '"'))
+ {
+ throw ThrowHelper.InvalidSqlIdentifier(identifier);
+ }
+ }
+
+ return identifier;
+ }
+
+ for (var i = 0; i < identifier.Length; i++)
+ {
+ var c = identifier[i];
+ if (!(char.IsAsciiLetter(c) || c == '_' || c >= 128
+ || (i > 0 && (char.IsAsciiDigit(c) || c == '$'))))
+ {
+ throw ThrowHelper.InvalidSqlIdentifier(identifier);
+ }
+ }
+
+ // Preserve PostgreSQL's folding of unquoted ASCII identifiers.
+ return "\"" + string.Create(identifier.Length, identifier, static (span, value) =>
+ {
+ for (var i = 0; i < value.Length; i++)
+ {
+ span[i] = value[i] is >= 'A' and <= 'Z' ? (char)(value[i] + ('a' - 'A')) : value[i];
+ }
+ }) + "\"";
+ }
+
+ public static string QualifiedIdentifier(string identifier)
+ {
+ var quoted = false;
+ for (var i = 0; i < identifier.Length; i++)
+ {
+ if (identifier[i] == '"')
+ {
+ quoted = !quoted;
+ }
+ else if (identifier[i] == '.' && !quoted)
+ {
+ return Identifier(identifier[..i]) + "." + Identifier(identifier[(i + 1)..]);
+ }
+ }
+
+ return Identifier(identifier);
+ }
+}
diff --git a/src/Mocha/src/Mocha.Transport.Postgres/Connection/PostgresSchemaMigrator.cs b/src/Mocha/src/Mocha.Transport.Postgres/Connection/PostgresSchemaMigrator.cs
index b745fd25a65..ac105a54ea1 100644
--- a/src/Mocha/src/Mocha.Transport.Postgres/Connection/PostgresSchemaMigrator.cs
+++ b/src/Mocha/src/Mocha.Transport.Postgres/Connection/PostgresSchemaMigrator.cs
@@ -1,115 +1,61 @@
-using Npgsql;
+using System.Text;
+using static Mocha.Transport.Postgres.PostgresMigrationSql;
namespace Mocha.Transport.Postgres;
-///
-/// Manages database schema migrations for the PostgreSQL messaging transport.
-/// Uses PostgreSQL advisory locks to prevent concurrent migration attempts.
-///
-internal sealed class PostgresSchemaMigrator
+internal static class PostgresSchemaMigrator
{
- private const int LockId = 958913715;
+ internal const int LockId = 958913715;
- private readonly IReadOnlyPostgresSchemaOptions _settings;
+ internal static (string Id, string Sql)[] GetMigrations(IReadOnlyPostgresSchemaOptions options)
+ =>
+ [
+ ("2026-03-06_InitialSchema", PostgresSchemaSql.InitialSchema(options)),
+ ("2026-03-06_AddTransportIndex", PostgresSchemaSql.AddTransportIndex(options)),
+ ("2026-03-06_AddConsumerManagement", PostgresSchemaSql.AddConsumerManagement(options))
+ ];
- public PostgresSchemaMigrator(IReadOnlyPostgresSchemaOptions settings)
+ public static string GenerateBody(IReadOnlyPostgresSchemaOptions options)
{
- _settings = settings;
- }
-
- public async Task MigrateAsync(NpgsqlConnection connection)
- {
- await using var transaction = await connection.BeginTransactionAsync(CancellationToken.None);
-
- await AcquireLockAsync(connection);
- await EnsureMigrationsTableAsync(connection, _settings);
-
- var migrations = new List
- {
- new("2026-03-06_InitialSchema", PostgresSchemaSql.InitialSchema(_settings)),
- new("2026-03-06_AddTransportIndex", PostgresSchemaSql.AddTransportIndex(_settings)),
- new("2026-03-06_AddConsumerManagement", PostgresSchemaSql.AddConsumerManagement(_settings))
- };
-
- var applied = await GetAppliedMigrationsAsync(connection, _settings);
-
- foreach (var migration in migrations)
- {
- if (!applied.Contains(migration.Id))
- {
- await ApplyMigrationAsync(connection, _settings, migration);
- }
- }
-
- await transaction.CommitAsync();
- }
-
- private static async Task AcquireLockAsync(NpgsqlConnection connection)
- {
- await using var cmd = connection.CreateCommand();
- cmd.CommandText = "SELECT pg_advisory_xact_lock(@lockId);";
- cmd.Parameters.AddWithValue("lockId", LockId);
-
- await cmd.ExecuteNonQueryAsync();
- }
-
- private static async Task EnsureMigrationsTableAsync(
- NpgsqlConnection connection,
- IReadOnlyPostgresSchemaOptions settings)
- {
- await using var cmd = connection.CreateCommand();
-
- cmd.CommandText = $"""
- CREATE SCHEMA IF NOT EXISTS {settings.Schema};
-
- CREATE TABLE IF NOT EXISTS {settings.MigrationsTable}
+ var history = QualifiedIdentifier(options.MigrationsTable);
+ var builder = new StringBuilder();
+ builder.Append($"SELECT pg_advisory_xact_lock({LockId});\n");
+ builder.Append(
+ $"""
+ CREATE SCHEMA IF NOT EXISTS {Identifier(options.Schema)};
+
+ CREATE TABLE IF NOT EXISTS {history}
(
migration_id text NOT NULL PRIMARY KEY,
applied_on timestamptz NOT NULL DEFAULT (now() at time zone 'utc')
);
- """;
-
- await cmd.ExecuteNonQueryAsync();
- }
+ """);
+ builder.Append('\n');
- private static async Task> GetAppliedMigrationsAsync(
- NpgsqlConnection connection,
- IReadOnlyPostgresSchemaOptions settings)
- {
- await using var cmd = connection.CreateCommand();
- cmd.CommandText = $"SELECT migration_id FROM {settings.MigrationsTable};";
-
- await using var reader = await cmd.ExecuteReaderAsync();
- var applied = new HashSet();
- while (await reader.ReadAsync())
+ foreach (var (id, sql) in GetMigrations(options))
{
- applied.Add(reader.GetString(0));
- }
-
- return applied;
- }
+ var indentedSql = string.Join("\n", sql.ReplaceLineEndings("\n").Split('\n')
+ .Select(line => line.Length == 0 ? line : " " + line));
+ var body =
+ $"""
+ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM {history} WHERE migration_id = {Literal(id)}) THEN
+ {indentedSql}
+ INSERT INTO {history} (migration_id) VALUES ({Literal(id)});
+ END IF;
+ END;
+ """;
+ var delimiter = "$mocha_migration$";
+ for (var suffix = 1; body.Contains(delimiter, StringComparison.Ordinal); suffix++)
+ {
+ delimiter = $"$mocha_migration_{suffix}$";
+ }
- private static async Task ApplyMigrationAsync(
- NpgsqlConnection connection,
- IReadOnlyPostgresSchemaOptions settings,
- MigrationInfo migration)
- {
- await using (var cmd = connection.CreateCommand())
- {
- cmd.CommandText = migration.Sql;
- await cmd.ExecuteNonQueryAsync();
+ builder.Append("\nDO ").Append(delimiter).Append('\n');
+ builder.Append(body).Append('\n');
+ builder.Append(delimiter).Append(";\n");
}
- await using (var insertCmd = connection.CreateCommand())
- {
- insertCmd.CommandText = $"""
- INSERT INTO {settings.MigrationsTable} (migration_id)
- VALUES (@id);
- """;
- insertCmd.Parameters.AddWithValue("id", migration.Id);
- await insertCmd.ExecuteNonQueryAsync();
- }
+ return builder.ToString().ReplaceLineEndings("\n");
}
-
- private record MigrationInfo(string Id, string Sql);
}
diff --git a/src/Mocha/src/Mocha.Transport.Postgres/Connection/PostgresSchemaSql.cs b/src/Mocha/src/Mocha.Transport.Postgres/Connection/PostgresSchemaSql.cs
index 7106e57f0d1..d7bad646f1a 100644
--- a/src/Mocha/src/Mocha.Transport.Postgres/Connection/PostgresSchemaSql.cs
+++ b/src/Mocha/src/Mocha.Transport.Postgres/Connection/PostgresSchemaSql.cs
@@ -1,3 +1,5 @@
+using static Mocha.Transport.Postgres.PostgresMigrationSql;
+
namespace Mocha.Transport.Postgres;
///
@@ -7,54 +9,54 @@ internal static class PostgresSchemaSql
{
public static string InitialSchema(IReadOnlyPostgresSchemaOptions s) =>
$"""
- CREATE SCHEMA IF NOT EXISTS {s.Schema};
+ CREATE SCHEMA IF NOT EXISTS {Identifier(s.Schema)};
- CREATE SEQUENCE IF NOT EXISTS {s.TopologySequence} AS bigint;
+ CREATE SEQUENCE IF NOT EXISTS {QualifiedIdentifier(s.TopologySequence)} AS bigint;
- CREATE TABLE IF NOT EXISTS {s.TopicTable}
+ CREATE TABLE IF NOT EXISTS {QualifiedIdentifier(s.TopicTable)}
(
- id bigint NOT NULL PRIMARY KEY DEFAULT nextval('{s.TopologySequence}'),
+ id bigint NOT NULL PRIMARY KEY DEFAULT nextval({Literal(QualifiedIdentifier(s.TopologySequence))}),
updated timestamptz NOT NULL DEFAULT (now() at time zone 'utc'),
name text NOT NULL
);
- CREATE UNIQUE INDEX IF NOT EXISTS {s.TablePrefix}topic_uqx ON {s.TopicTable} (name) INCLUDE (id);
- ALTER TABLE {s.TopicTable} ADD CONSTRAINT {s.TablePrefix}unique_topic UNIQUE USING INDEX {s.TablePrefix}topic_uqx;
+ CREATE UNIQUE INDEX IF NOT EXISTS {Identifier(s.TablePrefix + "topic_uqx")} ON {QualifiedIdentifier(s.TopicTable)} (name) INCLUDE (id);
+ ALTER TABLE {QualifiedIdentifier(s.TopicTable)} ADD CONSTRAINT {Identifier(s.TablePrefix + "unique_topic")} UNIQUE USING INDEX {Identifier(s.TablePrefix + "topic_uqx")};
- CREATE TABLE IF NOT EXISTS {s.QueueTable}
+ CREATE TABLE IF NOT EXISTS {QualifiedIdentifier(s.QueueTable)}
(
- id bigint NOT NULL PRIMARY KEY DEFAULT nextval('{s.TopologySequence}'),
+ id bigint NOT NULL PRIMARY KEY DEFAULT nextval({Literal(QualifiedIdentifier(s.TopologySequence))}),
updated timestamptz NOT NULL DEFAULT (now() at time zone 'utc'),
name text NOT NULL
);
- CREATE UNIQUE INDEX IF NOT EXISTS {s.TablePrefix}queue_uqx ON {s.QueueTable} (name) INCLUDE (id);
- ALTER TABLE {s.QueueTable} ADD CONSTRAINT {s.TablePrefix}unique_queue UNIQUE USING INDEX {s.TablePrefix}queue_uqx;
+ CREATE UNIQUE INDEX IF NOT EXISTS {Identifier(s.TablePrefix + "queue_uqx")} ON {QualifiedIdentifier(s.QueueTable)} (name) INCLUDE (id);
+ ALTER TABLE {QualifiedIdentifier(s.QueueTable)} ADD CONSTRAINT {Identifier(s.TablePrefix + "unique_queue")} UNIQUE USING INDEX {Identifier(s.TablePrefix + "queue_uqx")};
- CREATE TABLE IF NOT EXISTS {s.QueueSubscriptionTable}
+ CREATE TABLE IF NOT EXISTS {QualifiedIdentifier(s.QueueSubscriptionTable)}
(
- id bigint NOT NULL PRIMARY KEY DEFAULT nextval('{s.TopologySequence}'),
+ id bigint NOT NULL PRIMARY KEY DEFAULT nextval({Literal(QualifiedIdentifier(s.TopologySequence))}),
updated timestamptz NOT NULL DEFAULT (now() at time zone 'utc'),
- source_id bigint NOT NULL REFERENCES {s.TopicTable} (id) ON DELETE CASCADE,
- destination_id bigint NOT NULL REFERENCES {s.QueueTable} (id) ON DELETE CASCADE
+ source_id bigint NOT NULL REFERENCES {QualifiedIdentifier(s.TopicTable)} (id) ON DELETE CASCADE,
+ destination_id bigint NOT NULL REFERENCES {QualifiedIdentifier(s.QueueTable)} (id) ON DELETE CASCADE
);
- CREATE UNIQUE INDEX IF NOT EXISTS {s.TablePrefix}queue_subscription_uqx
- ON {s.QueueSubscriptionTable} (source_id, destination_id);
- ALTER TABLE {s.QueueSubscriptionTable}
- ADD CONSTRAINT {s.TablePrefix}unique_queue_subscription UNIQUE USING INDEX {s.TablePrefix}queue_subscription_uqx;
+ CREATE UNIQUE INDEX IF NOT EXISTS {Identifier(s.TablePrefix + "queue_subscription_uqx")}
+ ON {QualifiedIdentifier(s.QueueSubscriptionTable)} (source_id, destination_id);
+ ALTER TABLE {QualifiedIdentifier(s.QueueSubscriptionTable)}
+ ADD CONSTRAINT {Identifier(s.TablePrefix + "unique_queue_subscription")} UNIQUE USING INDEX {Identifier(s.TablePrefix + "queue_subscription_uqx")};
- CREATE INDEX IF NOT EXISTS {s.TablePrefix}queue_subscription_source_ndx
- ON {s.QueueSubscriptionTable} (source_id) INCLUDE (id, destination_id);
- CREATE INDEX IF NOT EXISTS {s.TablePrefix}queue_subscription_dest_ndx
- ON {s.QueueSubscriptionTable} (destination_id) INCLUDE (id, source_id);
+ CREATE INDEX IF NOT EXISTS {Identifier(s.TablePrefix + "queue_subscription_source_ndx")}
+ ON {QualifiedIdentifier(s.QueueSubscriptionTable)} (source_id) INCLUDE (id, destination_id);
+ CREATE INDEX IF NOT EXISTS {Identifier(s.TablePrefix + "queue_subscription_dest_ndx")}
+ ON {QualifiedIdentifier(s.QueueSubscriptionTable)} (destination_id) INCLUDE (id, source_id);
- CREATE TABLE IF NOT EXISTS {s.MessageTable}
+ CREATE TABLE IF NOT EXISTS {QualifiedIdentifier(s.MessageTable)}
(
transport_message_id uuid NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
body bytea NOT NULL,
headers jsonb,
- queue_id bigint NOT NULL REFERENCES {s.QueueTable} (id) ON DELETE CASCADE,
+ queue_id bigint NOT NULL REFERENCES {QualifiedIdentifier(s.QueueTable)} (id) ON DELETE CASCADE,
sent_time timestamptz NOT NULL DEFAULT (now() at time zone 'utc'),
scheduled_time timestamptz,
expiration_time timestamptz,
@@ -65,25 +67,25 @@ sent_time timestamptz NOT NULL DEFAULT (now() at time zone 'utc'),
error_reason jsonb
);
- CREATE INDEX IF NOT EXISTS {s.TablePrefix}message_queue_ndx
- ON {s.MessageTable} (queue_id) INCLUDE (transport_message_id);
+ CREATE INDEX IF NOT EXISTS {Identifier(s.TablePrefix + "message_queue_ndx")}
+ ON {QualifiedIdentifier(s.MessageTable)} (queue_id) INCLUDE (transport_message_id);
""";
public static string AddTransportIndex(IReadOnlyPostgresSchemaOptions s) =>
$"""
- CREATE INDEX IF NOT EXISTS {s.TablePrefix}message_transport_queue_ndx
- ON {s.MessageTable} (transport_message_id, queue_id);
+ CREATE INDEX IF NOT EXISTS {Identifier(s.TablePrefix + "message_transport_queue_ndx")}
+ ON {QualifiedIdentifier(s.MessageTable)} (transport_message_id, queue_id);
- CREATE INDEX IF NOT EXISTS {s.TablePrefix}message_expiration_scheduled_ndx
- ON {s.MessageTable} (expiration_time, scheduled_time);
+ CREATE INDEX IF NOT EXISTS {Identifier(s.TablePrefix + "message_expiration_scheduled_ndx")}
+ ON {QualifiedIdentifier(s.MessageTable)} (expiration_time, scheduled_time);
- CREATE INDEX IF NOT EXISTS {s.TablePrefix}message_sent_time_ndx
- ON {s.MessageTable} (sent_time);
+ CREATE INDEX IF NOT EXISTS {Identifier(s.TablePrefix + "message_sent_time_ndx")}
+ ON {QualifiedIdentifier(s.MessageTable)} (sent_time);
""";
public static string AddConsumerManagement(IReadOnlyPostgresSchemaOptions s) =>
$"""
- CREATE TABLE IF NOT EXISTS {s.ConsumersTable}
+ CREATE TABLE IF NOT EXISTS {QualifiedIdentifier(s.ConsumersTable)}
(
id uuid NOT NULL PRIMARY KEY,
service_name text NOT NULL,
@@ -91,13 +93,13 @@ created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
- ALTER TABLE {s.QueueTable}
- ADD COLUMN IF NOT EXISTS consumer_id uuid REFERENCES {s.ConsumersTable}(id) ON DELETE CASCADE;
+ ALTER TABLE {QualifiedIdentifier(s.QueueTable)}
+ ADD COLUMN IF NOT EXISTS consumer_id uuid REFERENCES {QualifiedIdentifier(s.ConsumersTable)}(id) ON DELETE CASCADE;
- CREATE INDEX IF NOT EXISTS {s.TablePrefix}consumers_updated_at_ndx
- ON {s.ConsumersTable} (updated_at);
+ CREATE INDEX IF NOT EXISTS {Identifier(s.TablePrefix + "consumers_updated_at_ndx")}
+ ON {QualifiedIdentifier(s.ConsumersTable)} (updated_at);
- CREATE INDEX IF NOT EXISTS {s.TablePrefix}queue_consumer_id_ndx
- ON {s.QueueTable} (consumer_id);
+ CREATE INDEX IF NOT EXISTS {Identifier(s.TablePrefix + "queue_consumer_id_ndx")}
+ ON {QualifiedIdentifier(s.QueueTable)} (consumer_id);
""";
}
diff --git a/src/Mocha/src/Mocha.Transport.Postgres/Descriptors/IPostgresMessagingTransportDescriptor.cs b/src/Mocha/src/Mocha.Transport.Postgres/Descriptors/IPostgresMessagingTransportDescriptor.cs
index 5bf7c1c3b0c..d0300aebf59 100644
--- a/src/Mocha/src/Mocha.Transport.Postgres/Descriptors/IPostgresMessagingTransportDescriptor.cs
+++ b/src/Mocha/src/Mocha.Transport.Postgres/Descriptors/IPostgresMessagingTransportDescriptor.cs
@@ -81,6 +81,12 @@ public interface IPostgresMessagingTransportDescriptor
/// The descriptor for method chaining.
IPostgresMessagingTransportDescriptor AutoProvision(bool autoProvision = true);
+ ///
+ /// Sets whether transport schema migrations run at startup. Defaults to true.
+ /// When false, startup skips migration without checking the schema.
+ ///
+ IPostgresMessagingTransportDescriptor AutoMigrate(bool autoMigrate);
+
///
new IPostgresMessagingTransportDescriptor Name(string name);
diff --git a/src/Mocha/src/Mocha.Transport.Postgres/Descriptors/PostgresMessagingTransportDescriptor.cs b/src/Mocha/src/Mocha.Transport.Postgres/Descriptors/PostgresMessagingTransportDescriptor.cs
index bf3fd95cd4e..7665c1aa08f 100644
--- a/src/Mocha/src/Mocha.Transport.Postgres/Descriptors/PostgresMessagingTransportDescriptor.cs
+++ b/src/Mocha/src/Mocha.Transport.Postgres/Descriptors/PostgresMessagingTransportDescriptor.cs
@@ -146,6 +146,13 @@ public IPostgresMessagingTransportDescriptor AutoProvision(bool autoProvision =
return this;
}
+ ///
+ public IPostgresMessagingTransportDescriptor AutoMigrate(bool autoMigrate)
+ {
+ Configuration.AutoMigrate = autoMigrate;
+ return this;
+ }
+
///
public IPostgresMessagingTransportDescriptor ConnectionString(string connectionString)
{
diff --git a/src/Mocha/src/Mocha.Transport.Postgres/Descriptors/PostgresQueueDescriptor.cs b/src/Mocha/src/Mocha.Transport.Postgres/Descriptors/PostgresQueueDescriptor.cs
index e41070c7bcf..3f651fdcc90 100644
--- a/src/Mocha/src/Mocha.Transport.Postgres/Descriptors/PostgresQueueDescriptor.cs
+++ b/src/Mocha/src/Mocha.Transport.Postgres/Descriptors/PostgresQueueDescriptor.cs
@@ -125,7 +125,7 @@ public IPostgresQueueDescriptor UseReceive(
{
if (before is not null && after is not null)
{
- throw ThrowHelper.BeforeAndAfterConflict();
+ throw Mocha.ThrowHelper.BeforeAndAfterConflict();
}
if (before is null && after is null)
diff --git a/src/Mocha/src/Mocha.Transport.Postgres/PostgresMessagingTransport.cs b/src/Mocha/src/Mocha.Transport.Postgres/PostgresMessagingTransport.cs
index 763daea4b92..2c9c1e6af46 100644
--- a/src/Mocha/src/Mocha.Transport.Postgres/PostgresMessagingTransport.cs
+++ b/src/Mocha/src/Mocha.Transport.Postgres/PostgresMessagingTransport.cs
@@ -14,6 +14,7 @@ public sealed class PostgresMessagingTransport : MessagingTransport
private readonly Action _configure;
private readonly PostgresBackgroundTaskScheduler _backgroundTasks = new();
private IReadOnlyPostgresSchemaOptions _schemaOptions = null!;
+ private bool _autoMigrate;
///
/// Creates a new PostgreSQL transport with the specified configuration delegate.
@@ -64,6 +65,7 @@ protected override void OnAfterInitialized(IMessagingSetupContext context)
}
_schemaOptions = configuration.SchemaOptions;
+ _autoMigrate = configuration.AutoMigrate;
var connectionManagerLogger = context.Services.GetRequiredService>();
ConnectionManager = new PostgresConnectionManager(
@@ -113,9 +115,8 @@ protected override void OnAfterInitialized(IMessagingSetupContext context)
}
///
- /// Ensures database migrations are run, registers the consumer, starts the notification listener,
- /// provisions topology resources, and starts background tasks before the transport's endpoints
- /// begin processing messages.
+ /// Prepares the configured schema and topology and registers the consumer before
+ /// the transport's endpoints begin processing messages.
///
/// The configuration context for the current startup phase.
/// A token to cancel the startup.
@@ -123,7 +124,11 @@ protected override async ValueTask OnBeforeStartAsync(
IMessagingConfigurationContext context,
CancellationToken cancellationToken)
{
- await ConnectionManager.EnsureMigratedAsync(cancellationToken);
+ if (_autoMigrate)
+ {
+ await ConnectionManager.EnsureMigratedAsync(cancellationToken);
+ }
+
await ConsumerManager.RegisterAsync(cancellationToken);
await NotificationListener.StartAsync(cancellationToken);
diff --git a/src/Mocha/src/Mocha.Transport.Postgres/PostgresTransportSchema.cs b/src/Mocha/src/Mocha.Transport.Postgres/PostgresTransportSchema.cs
new file mode 100644
index 00000000000..0503eead80f
--- /dev/null
+++ b/src/Mocha/src/Mocha.Transport.Postgres/PostgresTransportSchema.cs
@@ -0,0 +1,70 @@
+using System.Data;
+using System.Transactions;
+using Npgsql;
+
+namespace Mocha.Transport.Postgres;
+
+///
+/// Applies or generates migrations for the PostgreSQL transport schema.
+///
+public static class PostgresTransportSchema
+{
+ ///
+ /// Applies pending transport migrations in a transaction on an open, caller-owned connection.
+ /// The connection must have no active or ambient transaction and remains open after the operation.
+ ///
+ /// The open connection to the database to migrate.
+ /// The schema and table naming used by the transport.
+ /// A token to cancel migration, including waiting for its lock.
+ public static async Task MigrateAsync(
+ NpgsqlConnection connection,
+ IReadOnlyPostgresSchemaOptions schemaOptions,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(connection);
+ ArgumentNullException.ThrowIfNull(schemaOptions);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (connection.State != ConnectionState.Open)
+ {
+ throw ThrowHelper.MigrationConnectionMustBeOpen();
+ }
+
+ if (Transaction.Current is not null)
+ {
+ throw ThrowHelper.MigrationTransactionNotSupported(null);
+ }
+
+ var sql = PostgresSchemaMigrator.GenerateBody(schemaOptions);
+ NpgsqlTransaction transaction;
+ try
+ {
+ transaction = await connection.BeginTransactionAsync(cancellationToken);
+ }
+ catch (InvalidOperationException exception)
+ {
+ throw ThrowHelper.MigrationTransactionNotSupported(exception);
+ }
+
+ await using (transaction)
+ {
+ await using var command = connection.CreateCommand();
+ command.Transaction = transaction;
+ command.CommandText = sql;
+ await command.ExecuteNonQueryAsync(cancellationToken);
+ await transaction.CommitAsync(cancellationToken);
+ }
+ }
+
+ ///
+ /// Returns an idempotent forward migration script with transaction boundaries and LF line endings.
+ /// Generation is synchronous and does not access a database or file.
+ ///
+ /// The schema and table naming used by the transport.
+ public static string GenerateMigrationScript(IReadOnlyPostgresSchemaOptions schemaOptions)
+ {
+ ArgumentNullException.ThrowIfNull(schemaOptions);
+
+ return "BEGIN;\n" + PostgresSchemaMigrator.GenerateBody(schemaOptions) + "COMMIT;\n";
+ }
+}
diff --git a/src/Mocha/src/Mocha.Transport.Postgres/ThrowHelper.cs b/src/Mocha/src/Mocha.Transport.Postgres/ThrowHelper.cs
new file mode 100644
index 00000000000..fdd47a933f7
--- /dev/null
+++ b/src/Mocha/src/Mocha.Transport.Postgres/ThrowHelper.cs
@@ -0,0 +1,13 @@
+namespace Mocha.Transport.Postgres;
+
+internal static class ThrowHelper
+{
+ public static InvalidOperationException MigrationConnectionMustBeOpen()
+ => new("Transport migration requires an open PostgreSQL connection.");
+
+ public static InvalidOperationException MigrationTransactionNotSupported(Exception? innerException)
+ => new("Transport migration requires a connection without an active or ambient transaction.", innerException);
+
+ public static ArgumentException InvalidSqlIdentifier(string identifier)
+ => new($"'{identifier}' is not a valid PostgreSQL identifier.", nameof(identifier));
+}
diff --git a/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/Mocha.EntityFrameworkCore.Postgres.Tests.csproj b/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/Mocha.EntityFrameworkCore.Postgres.Tests.csproj
index 4d64f5b1923..bace2e87eb2 100644
--- a/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/Mocha.EntityFrameworkCore.Postgres.Tests.csproj
+++ b/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/Mocha.EntityFrameworkCore.Postgres.Tests.csproj
@@ -14,6 +14,7 @@
+
diff --git a/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/PostgresMigrationOutboxTests.cs b/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/PostgresMigrationOutboxTests.cs
new file mode 100644
index 00000000000..7477a36349b
--- /dev/null
+++ b/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/PostgresMigrationOutboxTests.cs
@@ -0,0 +1,89 @@
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Mocha.EntityFrameworkCore.Postgres.Tests.Helpers;
+using Mocha.Outbox;
+using Mocha.Transport.Postgres;
+using Npgsql;
+
+namespace Mocha.EntityFrameworkCore.Postgres.Tests;
+
+public sealed class PostgresMigrationOutboxTests(PostgresFixture fixture) : IClassFixture
+{
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task PublishAsync_Should_PersistOutboxMessage_When_DatabasePreparedBeforeRuntime(bool template)
+ {
+ // arrange
+ var ct = TestContext.Current.CancellationToken;
+ var source = new NpgsqlConnectionStringBuilder(await fixture.CreateDatabaseAsync()) { Pooling = false };
+ var options = new DbContextOptionsBuilder().UseTestNpgsql(source.ConnectionString).Options;
+ await using (var db = new TestDbContext(options))
+ {
+ await db.Database.EnsureCreatedAsync(ct);
+ await db.Database.OpenConnectionAsync(ct);
+ await PostgresTransportSchema.MigrateAsync(
+ (NpgsqlConnection)db.Database.GetDbConnection(), new PostgresSchemaOptions(), ct);
+ }
+
+ var runtimeConnection = source.ConnectionString;
+ if (template)
+ {
+ var clone = new NpgsqlConnectionStringBuilder(await fixture.CreateDatabaseAsync()) { Pooling = false };
+ var adminSettings = new NpgsqlConnectionStringBuilder(source.ConnectionString) { Database = "postgres" };
+ await using var admin = new NpgsqlConnection(adminSettings.ConnectionString);
+ await admin.OpenAsync(ct);
+ await using var command = admin.CreateCommand();
+ command.CommandText = $"DROP DATABASE \"{clone.Database}\";";
+ await command.ExecuteNonQueryAsync(ct);
+ command.CommandText = $"CREATE DATABASE \"{clone.Database}\" TEMPLATE \"{source.Database}\";";
+ await command.ExecuteNonQueryAsync(ct);
+ runtimeConnection = clone.ConnectionString;
+ }
+
+ var services = new ServiceCollection();
+ services.AddDbContext(o => o.UseTestNpgsql(runtimeConnection));
+ services.AddSingleton();
+ services.AddMessageBus()
+ .AddEntityFramework(ef => ef.UsePostgresOutbox())
+ .AddPostgres(t =>
+ {
+ t.ConnectionString(runtimeConnection).AutoMigrate(false).AutoProvision(true);
+ t.DeclareTopic("prepared-events");
+ t.DispatchEndpoint("prepared-events").ToTopic("prepared-events").Publish();
+ });
+ await using var provider = services.BuildServiceProvider();
+ var runtime = (MessagingRuntime)provider.GetRequiredService();
+ await using var transport = runtime.Transports.OfType().Single();
+ await runtime.StartAsync(ct);
+ try
+ {
+ // act
+ using (var scope = provider.CreateScope())
+ {
+ var bus = scope.ServiceProvider.GetRequiredService();
+ await bus.PublishAsync(new PreparedEvent("persisted-before-dispatch"), ct);
+ }
+
+ // assert
+ using var verification = provider.CreateScope();
+ var db = verification.ServiceProvider.GetRequiredService();
+ var message = await db.Set().AsNoTracking().SingleAsync(ct);
+ var body = message.Envelope.RootElement.GetProperty("body");
+ var persisted = body.Deserialize(new JsonSerializerOptions(JsonSerializerDefaults.Web));
+ Assert.Equal(new PreparedEvent("persisted-before-dispatch"), persisted);
+ Assert.Equal(0, message.TimesSent);
+ await db.Database.OpenConnectionAsync(ct);
+ await using var command = db.Database.GetDbConnection().CreateCommand();
+ command.CommandText = "SELECT count(*) FROM public.mocha_migrations;";
+ Assert.Equal(3L, await command.ExecuteScalarAsync(ct));
+ }
+ finally
+ {
+ await transport.StopAsync(runtime, CancellationToken.None);
+ }
+ }
+
+ public sealed record PreparedEvent(string Payload);
+}
diff --git a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Behaviors/AutoMigrateIntegrationTests.cs b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Behaviors/AutoMigrateIntegrationTests.cs
new file mode 100644
index 00000000000..b5ae9f08438
--- /dev/null
+++ b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Behaviors/AutoMigrateIntegrationTests.cs
@@ -0,0 +1,228 @@
+using System.Collections.Concurrent;
+using System.Diagnostics;
+using CookieCrumble;
+using Microsoft.Extensions.DependencyInjection;
+using Mocha.Transport.Postgres.Tests.Helpers;
+using Npgsql;
+
+namespace Mocha.Transport.Postgres.Tests.Behaviors;
+
+[Collection("Postgres")]
+public class AutoMigrateIntegrationTests(PostgresFixture fixture)
+{
+ private static CancellationToken CancellationToken => TestContext.Current.CancellationToken;
+
+ [Theory]
+ [InlineData(null, false)]
+ [InlineData(true, false)]
+ [InlineData(null, true)]
+ [InlineData(true, true)]
+ public async Task StartAsync_Should_Migrate_When_AutoMigrateEnabled(bool? autoMigrate, bool upgrade)
+ {
+ // arrange
+ await using var database = await fixture.CreateDatabaseAsync();
+ await using var connection = CreateSetupConnection(database.ConnectionString);
+ await connection.OpenAsync(CancellationToken);
+ if (upgrade)
+ {
+ await ExecuteAsync(connection, PostgresSchemaSql.InitialSchema(new PostgresSchemaOptions()));
+ await ExecuteAsync(connection,
+ """
+ CREATE TABLE public.mocha_migrations (
+ migration_id text PRIMARY KEY,
+ applied_on timestamptz NOT NULL DEFAULT now());
+ INSERT INTO public.mocha_migrations (migration_id) VALUES ('2026-03-06_InitialSchema');
+ """);
+ }
+
+ var services = new ServiceCollection();
+ services.AddMessageBus().AddPostgres(t =>
+ {
+ t.ConnectionString(database.ConnectionString).AutoProvision(false);
+ if (autoMigrate.HasValue)
+ {
+ t.AutoMigrate(autoMigrate.Value);
+ }
+ });
+ await using var provider = services.BuildServiceProvider();
+ var runtime = (MessagingRuntime)provider.GetRequiredService();
+ await using var transport = runtime.Transports.OfType().Single();
+
+ // act
+ await runtime.StartAsync(CancellationToken);
+ try
+ {
+ // assert
+ Assert.Equal(3L, await ScalarAsync(connection, "SELECT count(*) FROM public.mocha_migrations;"));
+ Assert.Equal(1L, await ScalarAsync(connection, "SELECT count(*) FROM public.mocha_consumers;"));
+ }
+ finally
+ {
+ await transport.StopAsync(runtime, CancellationToken.None);
+ }
+ }
+
+ [Fact]
+ public async Task StartAsync_Should_SkipSchemaWork_When_PrivilegedRuntimeDisablesMigration()
+ {
+ // arrange
+ await using var database = await fixture.CreateDatabaseAsync();
+ await using var connection = CreateSetupConnection(database.ConnectionString);
+ await connection.OpenAsync(CancellationToken);
+ await PostgresTransportSchema.MigrateAsync(connection, new PostgresSchemaOptions(), CancellationToken);
+ await ExecuteAsync(connection,
+ """
+ ALTER TABLE public.mocha_migrations RENAME TO saved_migrations;
+ CREATE FUNCTION public.reject_history_read() RETURNS text AS $$
+ BEGIN
+ RAISE EXCEPTION 'Migration history must not be read';
+ END;
+ $$ LANGUAGE plpgsql;
+ CREATE VIEW public.mocha_migrations AS
+ SELECT public.reject_history_read() AS migration_id;
+ CREATE FUNCTION public.reject_ddl() RETURNS event_trigger AS $$
+ BEGIN
+ RAISE EXCEPTION 'Schema DDL must not run';
+ END;
+ $$ LANGUAGE plpgsql;
+ CREATE EVENT TRIGGER reject_ddl ON ddl_command_start EXECUTE FUNCTION public.reject_ddl();
+ """);
+ var statements = new ConcurrentBag();
+ using var listener = new ActivityListener
+ {
+ ShouldListenTo = source => source.Name == "Npgsql",
+ Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded,
+ ActivityStopped = activity =>
+ {
+ if (activity.GetTagItem("db.statement") is string sql)
+ {
+ statements.Add(sql);
+ }
+ }
+ };
+ ActivitySource.AddActivityListener(listener);
+
+ // act
+ await PublishAndReceiveAsync(database.ConnectionString);
+ listener.Dispose();
+
+ // assert
+ Assert.Contains(statements, sql => sql.Contains("INSERT INTO public.mocha_consumers", StringComparison.Ordinal));
+ statements.Where(sql =>
+ sql.Contains("mocha_migrations", StringComparison.OrdinalIgnoreCase)
+ || sql.Contains("pg_catalog", StringComparison.OrdinalIgnoreCase)
+ || sql.Contains("information_schema", StringComparison.OrdinalIgnoreCase)
+ || sql.Contains("pg_tables", StringComparison.OrdinalIgnoreCase)
+ || sql.Contains("to_regclass", StringComparison.OrdinalIgnoreCase)
+ || sql.Contains("pg_advisory", StringComparison.OrdinalIgnoreCase))
+ .ToArray().MatchInlineSnapshot("[]");
+ Assert.Equal(3L, await ScalarAsync(connection, "SELECT count(*) FROM public.saved_migrations;"));
+ Assert.True((long)(await ScalarAsync(connection, "SELECT count(*) FROM public.mocha_queue;"))! > 0);
+ }
+
+ [Fact]
+ public async Task PublishAsync_Should_Deliver_When_RuntimeHasOnlyDmlPermissions()
+ {
+ // arrange
+ await using var database = await fixture.CreateDatabaseAsync();
+ await using var connection = CreateSetupConnection(database.ConnectionString);
+ await connection.OpenAsync(CancellationToken);
+ await PostgresTransportSchema.MigrateAsync(connection, new PostgresSchemaOptions(), CancellationToken);
+ var role = "mocha_runtime_" + Guid.NewGuid().ToString("N");
+ await ExecuteAsync(connection, $"CREATE ROLE {role} LOGIN PASSWORD 'runtime-test';");
+ try
+ {
+ await ExecuteAsync(connection,
+ $"""
+ GRANT CONNECT ON DATABASE "{database.DatabaseName}" TO {role};
+ GRANT USAGE ON SCHEMA public TO {role};
+ GRANT SELECT, INSERT, UPDATE, DELETE ON
+ public.mocha_topic, public.mocha_queue, public.mocha_queue_subscription,
+ public.mocha_message, public.mocha_consumers TO {role};
+ GRANT USAGE ON SEQUENCE public.mocha_topology_seq TO {role};
+ """);
+ var settings = new NpgsqlConnectionStringBuilder(database.ConnectionString)
+ {
+ Username = role,
+ Password = "runtime-test",
+ Pooling = false
+ };
+
+ // act
+ await PublishAndReceiveAsync(settings.ConnectionString);
+
+ // assert
+ Assert.Equal(false, await ScalarAsync(connection,
+ $"SELECT has_schema_privilege('{role}', 'public', 'CREATE');"));
+ Assert.Equal(false, await ScalarAsync(connection,
+ $"SELECT has_table_privilege('{role}', 'public.mocha_migrations', 'SELECT');"));
+ }
+ finally
+ {
+ await ExecuteAsync(connection, $"DROP OWNED BY {role}; DROP ROLE {role};");
+ }
+ }
+
+ [Fact]
+ public async Task StartAsync_Should_FailThroughConsumerRegistration_When_MigrationsDisabledOnEmptyDatabase()
+ {
+ // arrange
+ await using var database = await fixture.CreateDatabaseAsync();
+ var services = new ServiceCollection();
+ services.AddMessageBus().AddPostgres(t => t.ConnectionString(database.ConnectionString).AutoMigrate(false));
+ await using var provider = services.BuildServiceProvider();
+ var runtime = (MessagingRuntime)provider.GetRequiredService();
+ await using var transport = runtime.Transports.OfType().Single();
+
+ // act
+ var exception = await Assert.ThrowsAsync(() => runtime.StartAsync(CancellationToken).AsTask());
+
+ // assert
+ Assert.Equal(PostgresErrorCodes.UndefinedTable, exception.SqlState);
+ Assert.Equal("relation \"public.mocha_consumers\" does not exist", exception.MessageText);
+ await using var connection = CreateSetupConnection(database.ConnectionString);
+ await connection.OpenAsync(CancellationToken);
+ Assert.Equal(0L, await ScalarAsync(connection, "SELECT count(*) FROM pg_tables WHERE schemaname = 'public';"));
+ }
+
+ internal static async Task PublishAndReceiveAsync(string connectionString)
+ {
+ var recorder = new MessageRecorder();
+ var services = new ServiceCollection();
+ services.AddSingleton(recorder).AddMessageBus().AddEventHandler()
+ .AddPostgres(t => t.ConnectionString(connectionString).AutoMigrate(false).AutoProvision(true));
+ await using var provider = services.BuildServiceProvider();
+ var runtime = (MessagingRuntime)provider.GetRequiredService();
+ await using var transport = runtime.Transports.OfType().Single();
+ await runtime.StartAsync(CancellationToken);
+ try
+ {
+ using var scope = provider.CreateScope();
+ var bus = scope.ServiceProvider.GetRequiredService();
+ await bus.PublishAsync(new OrderCreated { OrderId = "prepared-database" }, CancellationToken);
+ Assert.True(await recorder.WaitAsync(TimeSpan.FromSeconds(30)), "Handler did not receive the event.");
+ Assert.Equal("prepared-database", Assert.IsType(Assert.Single(recorder.Messages)).OrderId);
+ }
+ finally
+ {
+ await transport.StopAsync(runtime, CancellationToken.None);
+ }
+ }
+
+ private static NpgsqlConnection CreateSetupConnection(string connectionString)
+ => new(new NpgsqlConnectionStringBuilder(connectionString) { Pooling = false }.ConnectionString);
+
+ private static async Task ExecuteAsync(NpgsqlConnection connection, string sql)
+ {
+ await using var command = connection.CreateCommand();
+ command.CommandText = sql;
+ await command.ExecuteNonQueryAsync(CancellationToken);
+ }
+
+ private static async Task
/// The schema and table naming used by the transport.
- public static string GenerateMigrationScript(IReadOnlyPostgresSchemaOptions schemaOptions)
+ public static string GenerateMigrationsSql(IReadOnlyPostgresSchemaOptions schemaOptions)
{
ArgumentNullException.ThrowIfNull(schemaOptions);
- return "BEGIN;\n" + PostgresSchemaMigrator.GenerateBody(schemaOptions) + "COMMIT;\n";
+ return "BEGIN;\n" + PostgresSchemaSql.GenerateMigrationsSql(schemaOptions) + "COMMIT;\n";
}
}
diff --git a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/PostgresMigrationScriptTests.cs b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/PostgresMigrationScriptTests.cs
index 4ffca7d4172..19fddee4021 100644
--- a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/PostgresMigrationScriptTests.cs
+++ b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/PostgresMigrationScriptTests.cs
@@ -5,16 +5,16 @@ namespace Mocha.Transport.Postgres.Tests.Connection;
public class PostgresMigrationScriptTests
{
[Fact]
- public void GenerateMigrationScript_Should_ReturnDeterministicSql_When_UsingDefaults()
+ public void GenerateMigrationsSql_Should_ReturnDeterministicSql_When_UsingDefaults()
{
// arrange
var options = new PostgresSchemaOptions();
// act
- var script = PostgresTransportSchema.GenerateMigrationScript(options);
+ var script = PostgresTransportSchema.GenerateMigrationsSql(options);
// assert
- Assert.Equal(script, PostgresTransportSchema.GenerateMigrationScript(options));
+ Assert.Equal(script, PostgresTransportSchema.GenerateMigrationsSql(options));
Assert.Equal(script.ReplaceLineEndings("\n"), script);
script.MatchSnapshot();
}
@@ -24,14 +24,14 @@ public void GenerateMigrationScript_Should_ReturnDeterministicSql_When_UsingDefa
[InlineData("\"unclosed")]
[InlineData("\"invalid\"quote\"")]
[InlineData("")]
- public void GenerateMigrationScript_Should_RejectInvalidIdentifier_When_SchemaIsMalformed(string schema)
+ public void GenerateMigrationsSql_Should_RejectInvalidIdentifier_When_SchemaIsMalformed(string schema)
{
// arrange
var options = new PostgresSchemaOptions { Schema = schema };
// act
var error = Assert.Throws(
- () => PostgresTransportSchema.GenerateMigrationScript(options));
+ () => PostgresTransportSchema.GenerateMigrationsSql(options));
// assert
Assert.Equal("identifier", error.ParamName);
diff --git a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/PostgresTransportSchemaTests.cs b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/PostgresTransportSchemaTests.cs
index b40f3b1091d..0fc60bba61d 100644
--- a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/PostgresTransportSchemaTests.cs
+++ b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/PostgresTransportSchemaTests.cs
@@ -36,7 +36,7 @@ public async Task MigrateAsync_Should_CreateTransportSchema_When_DatabaseIsEmpty
}
[Fact]
- public async Task GenerateMigrationScript_Should_MatchDirectMigration_When_AppliedWithPsql()
+ public async Task GenerateMigrationsSql_Should_MatchDirectMigration_When_AppliedWithPsql()
{
// arrange
await using var direct = await fixture.CreateDatabaseAsync("ScriptParityDirect");
@@ -44,7 +44,7 @@ public async Task GenerateMigrationScript_Should_MatchDirectMigration_When_Appli
await using var connection = await OpenAsync(direct.ConnectionString);
var options = new PostgresSchemaOptions();
await PostgresTransportSchema.MigrateAsync(connection, options, CancellationToken);
- var script = PostgresTransportSchema.GenerateMigrationScript(options);
+ var script = PostgresTransportSchema.GenerateMigrationsSql(options);
// act
await fixture.ApplyScriptAsync(scripted.DatabaseName, script, CancellationToken);
@@ -84,7 +84,7 @@ INSERT INTO public.mocha_message (body, queue_id)
if (script)
{
await fixture.ApplyScriptAsync(database.DatabaseName,
- PostgresTransportSchema.GenerateMigrationScript(options), CancellationToken);
+ PostgresTransportSchema.GenerateMigrationsSql(options), CancellationToken);
}
else
{
@@ -318,7 +318,7 @@ private static async Task OpenAsync(string connectionString)
private static Task ApplyAsync(NpgsqlConnection connection, PostgresSchemaOptions options, bool script)
=> script
- ? ExecuteAsync(connection, PostgresTransportSchema.GenerateMigrationScript(options))
+ ? ExecuteAsync(connection, PostgresTransportSchema.GenerateMigrationsSql(options))
: PostgresTransportSchema.MigrateAsync(connection, options, CancellationToken);
private static async Task ExecuteAsync(NpgsqlConnection connection, string sql)
diff --git a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/__snapshots__/PostgresMigrationScriptTests.GenerateMigrationScript_Should_ReturnDeterministicSql_When_UsingDefaults.snap b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/__snapshots__/PostgresMigrationScriptTests.GenerateMigrationsSql_Should_ReturnDeterministicSql_When_UsingDefaults.snap
similarity index 100%
rename from src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/__snapshots__/PostgresMigrationScriptTests.GenerateMigrationScript_Should_ReturnDeterministicSql_When_UsingDefaults.snap
rename to src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/__snapshots__/PostgresMigrationScriptTests.GenerateMigrationsSql_Should_ReturnDeterministicSql_When_UsingDefaults.snap
diff --git a/website/content/docs/mocha/transports/postgres.md b/website/content/docs/mocha/transports/postgres.md
index 2049c8200cd..f03b3392985 100644
--- a/website/content/docs/mocha/transports/postgres.md
+++ b/website/content/docs/mocha/transports/postgres.md
@@ -284,10 +284,10 @@ Transport migration creates transport tables, indexes, constraints, the topology
### Export a migration SQL artifact
-`GenerateMigrationScript` synchronously returns SQL without connecting to PostgreSQL, constructing a host, or discovering application topology:
+`GenerateMigrationsSql` synchronously returns SQL without connecting to PostgreSQL, constructing a host, or discovering application topology:
```csharp
-var sql = PostgresTransportSchema.GenerateMigrationScript(new PostgresSchemaOptions());
+var sql = PostgresTransportSchema.GenerateMigrationsSql(new PostgresSchemaOptions());
await File.WriteAllTextAsync("mocha-migrations.sql", sql, cancellationToken);
```
@@ -327,7 +327,7 @@ if (args is ["mocha", .. var command])
if (command is ["export", "--file", var file])
{
ct.ThrowIfCancellationRequested();
- var sql = PostgresTransportSchema.GenerateMigrationScript(options);
+ var sql = PostgresTransportSchema.GenerateMigrationsSql(options);
var destination = Path.GetFullPath(file);
var temporary = destination + "." + Guid.NewGuid().ToString("N") + ".tmp";
try
From 844241b1a0626c9942cb8d5acd7a68daaeae5f2c Mon Sep 17 00:00:00 2001
From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com>
Date: Tue, 6 Oct 2026 11:55:10 +0000
Subject: [PATCH 3/8] Cleanup
---
.../PostgresSchemaSql.Migrations.cs | 104 +++++++++----
.../Connection/PostgresSchemaSql.cs | 137 +-----------------
.../PostgresTransportSchema.cs | 3 -
.../Mocha.Transport.Postgres/ThrowHelper.cs | 3 -
.../PostgresMigrationScriptTests.cs | 18 ---
.../PostgresTransportSchemaTests.cs | 15 +-
...rnDeterministicSql_When_UsingDefaults.snap | 94 ++++++------
...eTransportSchema_When_DatabaseIsEmpty.snap | 85 -----------
.../Helpers/PostgresFixture.cs | 20 +--
9 files changed, 132 insertions(+), 347 deletions(-)
delete mode 100644 src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/__snapshots__/PostgresTransportSchemaTests.MigrateAsync_Should_CreateTransportSchema_When_DatabaseIsEmpty.snap
diff --git a/src/Mocha/src/Mocha.Transport.Postgres/Connection/PostgresSchemaSql.Migrations.cs b/src/Mocha/src/Mocha.Transport.Postgres/Connection/PostgresSchemaSql.Migrations.cs
index a7c80dc7326..d36356f0653 100644
--- a/src/Mocha/src/Mocha.Transport.Postgres/Connection/PostgresSchemaSql.Migrations.cs
+++ b/src/Mocha/src/Mocha.Transport.Postgres/Connection/PostgresSchemaSql.Migrations.cs
@@ -5,9 +5,9 @@ internal static partial class PostgresSchemaSql
private static string Bootstrap(IReadOnlyPostgresSchemaOptions s)
=> $"""
SELECT pg_advisory_xact_lock({LockId});
- CREATE SCHEMA IF NOT EXISTS {SchemaName(s)};
+ CREATE SCHEMA IF NOT EXISTS {s.Schema};
- CREATE TABLE IF NOT EXISTS {MigrationsTable(s)}
+ CREATE TABLE IF NOT EXISTS {s.MigrationsTable}
(
migration_id text NOT NULL PRIMARY KEY,
applied_on timestamptz NOT NULL DEFAULT (now() at time zone 'utc')
@@ -16,54 +16,54 @@ applied_on timestamptz NOT NULL DEFAULT (now() at time zone 'utc')
public static string InitialSchema(IReadOnlyPostgresSchemaOptions s) =>
$"""
- CREATE SCHEMA IF NOT EXISTS {SchemaName(s)};
+ CREATE SCHEMA IF NOT EXISTS {s.Schema};
- CREATE SEQUENCE IF NOT EXISTS {TopologySequence(s)} AS bigint;
+ CREATE SEQUENCE IF NOT EXISTS {s.TopologySequence} AS bigint;
- CREATE TABLE IF NOT EXISTS {TopicTable(s)}
+ CREATE TABLE IF NOT EXISTS {s.TopicTable}
(
- id bigint NOT NULL PRIMARY KEY DEFAULT nextval({TopologySequenceLiteral(s)}),
+ id bigint NOT NULL PRIMARY KEY DEFAULT nextval({Literal(s.TopologySequence)}),
updated timestamptz NOT NULL DEFAULT (now() at time zone 'utc'),
name text NOT NULL
);
- CREATE UNIQUE INDEX IF NOT EXISTS {TopicUniqueIndex(s)} ON {TopicTable(s)} (name) INCLUDE (id);
- ALTER TABLE {TopicTable(s)} ADD CONSTRAINT {TopicUniqueConstraint(s)} UNIQUE USING INDEX {TopicUniqueIndex(s)};
+ CREATE UNIQUE INDEX IF NOT EXISTS {TopicUniqueIndex(s)} ON {s.TopicTable} (name) INCLUDE (id);
+ ALTER TABLE {s.TopicTable} ADD CONSTRAINT {TopicUniqueConstraint(s)} UNIQUE USING INDEX {TopicUniqueIndex(s)};
- CREATE TABLE IF NOT EXISTS {QueueTable(s)}
+ CREATE TABLE IF NOT EXISTS {s.QueueTable}
(
- id bigint NOT NULL PRIMARY KEY DEFAULT nextval({TopologySequenceLiteral(s)}),
+ id bigint NOT NULL PRIMARY KEY DEFAULT nextval({Literal(s.TopologySequence)}),
updated timestamptz NOT NULL DEFAULT (now() at time zone 'utc'),
name text NOT NULL
);
- CREATE UNIQUE INDEX IF NOT EXISTS {QueueUniqueIndex(s)} ON {QueueTable(s)} (name) INCLUDE (id);
- ALTER TABLE {QueueTable(s)} ADD CONSTRAINT {QueueUniqueConstraint(s)} UNIQUE USING INDEX {QueueUniqueIndex(s)};
+ CREATE UNIQUE INDEX IF NOT EXISTS {QueueUniqueIndex(s)} ON {s.QueueTable} (name) INCLUDE (id);
+ ALTER TABLE {s.QueueTable} ADD CONSTRAINT {QueueUniqueConstraint(s)} UNIQUE USING INDEX {QueueUniqueIndex(s)};
- CREATE TABLE IF NOT EXISTS {QueueSubscriptionTable(s)}
+ CREATE TABLE IF NOT EXISTS {s.QueueSubscriptionTable}
(
- id bigint NOT NULL PRIMARY KEY DEFAULT nextval({TopologySequenceLiteral(s)}),
+ id bigint NOT NULL PRIMARY KEY DEFAULT nextval({Literal(s.TopologySequence)}),
updated timestamptz NOT NULL DEFAULT (now() at time zone 'utc'),
- source_id bigint NOT NULL REFERENCES {TopicTable(s)} (id) ON DELETE CASCADE,
- destination_id bigint NOT NULL REFERENCES {QueueTable(s)} (id) ON DELETE CASCADE
+ source_id bigint NOT NULL REFERENCES {s.TopicTable} (id) ON DELETE CASCADE,
+ destination_id bigint NOT NULL REFERENCES {s.QueueTable} (id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS {QueueSubscriptionUniqueIndex(s)}
- ON {QueueSubscriptionTable(s)} (source_id, destination_id);
- ALTER TABLE {QueueSubscriptionTable(s)}
+ ON {s.QueueSubscriptionTable} (source_id, destination_id);
+ ALTER TABLE {s.QueueSubscriptionTable}
ADD CONSTRAINT {QueueSubscriptionUniqueConstraint(s)} UNIQUE USING INDEX {QueueSubscriptionUniqueIndex(s)};
CREATE INDEX IF NOT EXISTS {QueueSubscriptionSourceIndex(s)}
- ON {QueueSubscriptionTable(s)} (source_id) INCLUDE (id, destination_id);
+ ON {s.QueueSubscriptionTable} (source_id) INCLUDE (id, destination_id);
CREATE INDEX IF NOT EXISTS {QueueSubscriptionDestinationIndex(s)}
- ON {QueueSubscriptionTable(s)} (destination_id) INCLUDE (id, source_id);
+ ON {s.QueueSubscriptionTable} (destination_id) INCLUDE (id, source_id);
- CREATE TABLE IF NOT EXISTS {MessageTable(s)}
+ CREATE TABLE IF NOT EXISTS {s.MessageTable}
(
transport_message_id uuid NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
body bytea NOT NULL,
headers jsonb,
- queue_id bigint NOT NULL REFERENCES {QueueTable(s)} (id) ON DELETE CASCADE,
+ queue_id bigint NOT NULL REFERENCES {s.QueueTable} (id) ON DELETE CASCADE,
sent_time timestamptz NOT NULL DEFAULT (now() at time zone 'utc'),
scheduled_time timestamptz,
expiration_time timestamptz,
@@ -75,24 +75,24 @@ error_reason jsonb
);
CREATE INDEX IF NOT EXISTS {MessageQueueIndex(s)}
- ON {MessageTable(s)} (queue_id) INCLUDE (transport_message_id);
+ ON {s.MessageTable} (queue_id) INCLUDE (transport_message_id);
""";
private static string AddTransportIndex(IReadOnlyPostgresSchemaOptions s) =>
$"""
CREATE INDEX IF NOT EXISTS {MessageTransportQueueIndex(s)}
- ON {MessageTable(s)} (transport_message_id, queue_id);
+ ON {s.MessageTable} (transport_message_id, queue_id);
CREATE INDEX IF NOT EXISTS {MessageExpirationScheduledIndex(s)}
- ON {MessageTable(s)} (expiration_time, scheduled_time);
+ ON {s.MessageTable} (expiration_time, scheduled_time);
CREATE INDEX IF NOT EXISTS {MessageSentTimeIndex(s)}
- ON {MessageTable(s)} (sent_time);
+ ON {s.MessageTable} (sent_time);
""";
private static string AddConsumerManagement(IReadOnlyPostgresSchemaOptions s) =>
$"""
- CREATE TABLE IF NOT EXISTS {ConsumersTable(s)}
+ CREATE TABLE IF NOT EXISTS {s.ConsumersTable}
(
id uuid NOT NULL PRIMARY KEY,
service_name text NOT NULL,
@@ -100,13 +100,55 @@ created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
- ALTER TABLE {QueueTable(s)}
- ADD COLUMN IF NOT EXISTS consumer_id uuid REFERENCES {ConsumersTable(s)}(id) ON DELETE CASCADE;
+ ALTER TABLE {s.QueueTable}
+ ADD COLUMN IF NOT EXISTS consumer_id uuid REFERENCES {s.ConsumersTable}(id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS {ConsumersUpdatedAtIndex(s)}
- ON {ConsumersTable(s)} (updated_at);
+ ON {s.ConsumersTable} (updated_at);
CREATE INDEX IF NOT EXISTS {QueueConsumerIdIndex(s)}
- ON {QueueTable(s)} (consumer_id);
+ ON {s.QueueTable} (consumer_id);
""";
+
+ private static string TopicUniqueIndex(IReadOnlyPostgresSchemaOptions options)
+ => options.TablePrefix + "topic_uqx";
+
+ private static string TopicUniqueConstraint(IReadOnlyPostgresSchemaOptions options)
+ => options.TablePrefix + "unique_topic";
+
+ private static string QueueUniqueIndex(IReadOnlyPostgresSchemaOptions options)
+ => options.TablePrefix + "queue_uqx";
+
+ private static string QueueUniqueConstraint(IReadOnlyPostgresSchemaOptions options)
+ => options.TablePrefix + "unique_queue";
+
+ private static string QueueSubscriptionUniqueIndex(IReadOnlyPostgresSchemaOptions options)
+ => options.TablePrefix + "queue_subscription_uqx";
+
+ private static string QueueSubscriptionUniqueConstraint(IReadOnlyPostgresSchemaOptions options)
+ => options.TablePrefix + "unique_queue_subscription";
+
+ private static string QueueSubscriptionSourceIndex(IReadOnlyPostgresSchemaOptions options)
+ => options.TablePrefix + "queue_subscription_source_ndx";
+
+ private static string QueueSubscriptionDestinationIndex(IReadOnlyPostgresSchemaOptions options)
+ => options.TablePrefix + "queue_subscription_dest_ndx";
+
+ private static string MessageQueueIndex(IReadOnlyPostgresSchemaOptions options)
+ => options.TablePrefix + "message_queue_ndx";
+
+ private static string MessageTransportQueueIndex(IReadOnlyPostgresSchemaOptions options)
+ => options.TablePrefix + "message_transport_queue_ndx";
+
+ private static string MessageExpirationScheduledIndex(IReadOnlyPostgresSchemaOptions options)
+ => options.TablePrefix + "message_expiration_scheduled_ndx";
+
+ private static string MessageSentTimeIndex(IReadOnlyPostgresSchemaOptions options)
+ => options.TablePrefix + "message_sent_time_ndx";
+
+ private static string ConsumersUpdatedAtIndex(IReadOnlyPostgresSchemaOptions options)
+ => options.TablePrefix + "consumers_updated_at_ndx";
+
+ private static string QueueConsumerIdIndex(IReadOnlyPostgresSchemaOptions options)
+ => options.TablePrefix + "queue_consumer_id_ndx";
}
diff --git a/src/Mocha/src/Mocha.Transport.Postgres/Connection/PostgresSchemaSql.cs b/src/Mocha/src/Mocha.Transport.Postgres/Connection/PostgresSchemaSql.cs
index 227a3d3d3da..126e7f8f1cd 100644
--- a/src/Mocha/src/Mocha.Transport.Postgres/Connection/PostgresSchemaSql.cs
+++ b/src/Mocha/src/Mocha.Transport.Postgres/Connection/PostgresSchemaSql.cs
@@ -18,15 +18,14 @@ public static string GenerateMigrationsSql(IReadOnlyPostgresSchemaOptions s)
private static string Migration(IReadOnlyPostgresSchemaOptions s, string id, string sql)
{
- var migrationHistory = MigrationsTable(s);
var migrationIdLiteral = Literal(id);
return DoBlock(
$"""
BEGIN
- IF NOT EXISTS (SELECT 1 FROM {migrationHistory} WHERE migration_id = {migrationIdLiteral}) THEN
+ IF NOT EXISTS (SELECT 1 FROM {s.MigrationsTable} WHERE migration_id = {migrationIdLiteral}) THEN
{Indent(sql, 8)}
- INSERT INTO {migrationHistory} (migration_id) VALUES ({migrationIdLiteral});
+ INSERT INTO {s.MigrationsTable} (migration_id) VALUES ({migrationIdLiteral});
END IF;
END;
""");
@@ -55,138 +54,6 @@ private static string Indent(string sql, int spaces)
.Select(line => line.Length == 0 ? line : indentation + line));
}
- private static string SchemaName(IReadOnlyPostgresSchemaOptions options)
- => Identifier(options.Schema);
-
- private static string TopicTable(IReadOnlyPostgresSchemaOptions options)
- => QualifiedIdentifier(options.TopicTable);
-
- private static string QueueTable(IReadOnlyPostgresSchemaOptions options)
- => QualifiedIdentifier(options.QueueTable);
-
- private static string QueueSubscriptionTable(IReadOnlyPostgresSchemaOptions options)
- => QualifiedIdentifier(options.QueueSubscriptionTable);
-
- private static string MessageTable(IReadOnlyPostgresSchemaOptions options)
- => QualifiedIdentifier(options.MessageTable);
-
- private static string ConsumersTable(IReadOnlyPostgresSchemaOptions options)
- => QualifiedIdentifier(options.ConsumersTable);
-
- private static string MigrationsTable(IReadOnlyPostgresSchemaOptions options)
- => QualifiedIdentifier(options.MigrationsTable);
-
- private static string TopologySequence(IReadOnlyPostgresSchemaOptions options)
- => QualifiedIdentifier(options.TopologySequence);
-
- private static string TopologySequenceLiteral(IReadOnlyPostgresSchemaOptions options)
- => Literal(TopologySequence(options));
-
- private static string TopicUniqueIndex(IReadOnlyPostgresSchemaOptions options)
- => Identifier(options.TablePrefix + "topic_uqx");
-
- private static string TopicUniqueConstraint(IReadOnlyPostgresSchemaOptions options)
- => Identifier(options.TablePrefix + "unique_topic");
-
- private static string QueueUniqueIndex(IReadOnlyPostgresSchemaOptions options)
- => Identifier(options.TablePrefix + "queue_uqx");
-
- private static string QueueUniqueConstraint(IReadOnlyPostgresSchemaOptions options)
- => Identifier(options.TablePrefix + "unique_queue");
-
- private static string QueueSubscriptionUniqueIndex(IReadOnlyPostgresSchemaOptions options)
- => Identifier(options.TablePrefix + "queue_subscription_uqx");
-
- private static string QueueSubscriptionUniqueConstraint(IReadOnlyPostgresSchemaOptions options)
- => Identifier(options.TablePrefix + "unique_queue_subscription");
-
- private static string QueueSubscriptionSourceIndex(IReadOnlyPostgresSchemaOptions options)
- => Identifier(options.TablePrefix + "queue_subscription_source_ndx");
-
- private static string QueueSubscriptionDestinationIndex(IReadOnlyPostgresSchemaOptions options)
- => Identifier(options.TablePrefix + "queue_subscription_dest_ndx");
-
- private static string MessageQueueIndex(IReadOnlyPostgresSchemaOptions options)
- => Identifier(options.TablePrefix + "message_queue_ndx");
-
- private static string MessageTransportQueueIndex(IReadOnlyPostgresSchemaOptions options)
- => Identifier(options.TablePrefix + "message_transport_queue_ndx");
-
- private static string MessageExpirationScheduledIndex(IReadOnlyPostgresSchemaOptions options)
- => Identifier(options.TablePrefix + "message_expiration_scheduled_ndx");
-
- private static string MessageSentTimeIndex(IReadOnlyPostgresSchemaOptions options)
- => Identifier(options.TablePrefix + "message_sent_time_ndx");
-
- private static string ConsumersUpdatedAtIndex(IReadOnlyPostgresSchemaOptions options)
- => Identifier(options.TablePrefix + "consumers_updated_at_ndx");
-
- private static string QueueConsumerIdIndex(IReadOnlyPostgresSchemaOptions options)
- => Identifier(options.TablePrefix + "queue_consumer_id_ndx");
-
private static string Literal(string value)
=> "E'" + value.Replace("\\", "\\\\").Replace("'", "''") + "'";
-
- private static string Identifier(string identifier)
- {
- if (string.IsNullOrEmpty(identifier) || identifier.Any(char.IsControl))
- {
- throw ThrowHelper.InvalidSqlIdentifier(identifier);
- }
-
- if (identifier[0] == '"')
- {
- if (identifier.Length < 3 || identifier[^1] != '"')
- {
- throw ThrowHelper.InvalidSqlIdentifier(identifier);
- }
-
- for (var i = 1; i < identifier.Length - 1; i++)
- {
- if (identifier[i] == '"' && (++i >= identifier.Length - 1 || identifier[i] != '"'))
- {
- throw ThrowHelper.InvalidSqlIdentifier(identifier);
- }
- }
-
- return identifier;
- }
-
- for (var i = 0; i < identifier.Length; i++)
- {
- var c = identifier[i];
- if (!(char.IsAsciiLetter(c) || c == '_' || c >= 128
- || (i > 0 && (char.IsAsciiDigit(c) || c == '$'))))
- {
- throw ThrowHelper.InvalidSqlIdentifier(identifier);
- }
- }
-
- // Preserve PostgreSQL's folding of unquoted ASCII identifiers.
- return "\"" + string.Create(identifier.Length, identifier, static (span, value) =>
- {
- for (var i = 0; i < value.Length; i++)
- {
- span[i] = value[i] is >= 'A' and <= 'Z' ? (char)(value[i] + ('a' - 'A')) : value[i];
- }
- }) + "\"";
- }
-
- private static string QualifiedIdentifier(string identifier)
- {
- var quoted = false;
- for (var i = 0; i < identifier.Length; i++)
- {
- if (identifier[i] == '"')
- {
- quoted = !quoted;
- }
- else if (identifier[i] == '.' && !quoted)
- {
- return Identifier(identifier[..i]) + "." + Identifier(identifier[(i + 1)..]);
- }
- }
-
- return Identifier(identifier);
- }
}
diff --git a/src/Mocha/src/Mocha.Transport.Postgres/PostgresTransportSchema.cs b/src/Mocha/src/Mocha.Transport.Postgres/PostgresTransportSchema.cs
index 3a43b6c7bdb..26416bdd9ec 100644
--- a/src/Mocha/src/Mocha.Transport.Postgres/PostgresTransportSchema.cs
+++ b/src/Mocha/src/Mocha.Transport.Postgres/PostgresTransportSchema.cs
@@ -4,9 +4,6 @@
namespace Mocha.Transport.Postgres;
-///
-/// Applies or generates migrations for the PostgreSQL transport schema.
-///
public static class PostgresTransportSchema
{
///
diff --git a/src/Mocha/src/Mocha.Transport.Postgres/ThrowHelper.cs b/src/Mocha/src/Mocha.Transport.Postgres/ThrowHelper.cs
index fdd47a933f7..815bada1668 100644
--- a/src/Mocha/src/Mocha.Transport.Postgres/ThrowHelper.cs
+++ b/src/Mocha/src/Mocha.Transport.Postgres/ThrowHelper.cs
@@ -7,7 +7,4 @@ public static InvalidOperationException MigrationConnectionMustBeOpen()
public static InvalidOperationException MigrationTransactionNotSupported(Exception? innerException)
=> new("Transport migration requires a connection without an active or ambient transaction.", innerException);
-
- public static ArgumentException InvalidSqlIdentifier(string identifier)
- => new($"'{identifier}' is not a valid PostgreSQL identifier.", nameof(identifier));
}
diff --git a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/PostgresMigrationScriptTests.cs b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/PostgresMigrationScriptTests.cs
index 19fddee4021..7a8f60ae806 100644
--- a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/PostgresMigrationScriptTests.cs
+++ b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/PostgresMigrationScriptTests.cs
@@ -18,22 +18,4 @@ public void GenerateMigrationsSql_Should_ReturnDeterministicSql_When_UsingDefaul
Assert.Equal(script.ReplaceLineEndings("\n"), script);
script.MatchSnapshot();
}
-
- [Theory]
- [InlineData("public; DROP SCHEMA public CASCADE")]
- [InlineData("\"unclosed")]
- [InlineData("\"invalid\"quote\"")]
- [InlineData("")]
- public void GenerateMigrationsSql_Should_RejectInvalidIdentifier_When_SchemaIsMalformed(string schema)
- {
- // arrange
- var options = new PostgresSchemaOptions { Schema = schema };
-
- // act
- var error = Assert.Throws(
- () => PostgresTransportSchema.GenerateMigrationsSql(options));
-
- // assert
- Assert.Equal("identifier", error.ParamName);
- }
}
diff --git a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/PostgresTransportSchemaTests.cs b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/PostgresTransportSchemaTests.cs
index 0fc60bba61d..43223e2a903 100644
--- a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/PostgresTransportSchemaTests.cs
+++ b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/PostgresTransportSchemaTests.cs
@@ -13,7 +13,7 @@ public class PostgresTransportSchemaTests(PostgresFixture fixture)
private static CancellationToken CancellationToken => TestContext.Current.CancellationToken;
[Fact]
- public async Task MigrateAsync_Should_CreateTransportSchema_When_DatabaseIsEmpty()
+ public async Task MigrateAsync_Should_RecordMigrations_When_DatabaseIsEmpty()
{
// arrange
await using var database = await fixture.CreateDatabaseAsync();
@@ -24,7 +24,6 @@ public async Task MigrateAsync_Should_CreateTransportSchema_When_DatabaseIsEmpty
// assert
Assert.Equal(ConnectionState.Open, connection.State);
- (await ReadSchemaAsync(connection)).MatchSnapshot();
(await ReadHistoryAsync(connection)).MatchInlineSnapshot(
"""
[
@@ -36,7 +35,7 @@ public async Task MigrateAsync_Should_CreateTransportSchema_When_DatabaseIsEmpty
}
[Fact]
- public async Task GenerateMigrationsSql_Should_MatchDirectMigration_When_AppliedWithPsql()
+ public async Task GenerateMigrationsSql_Should_MatchDirectMigration_When_ScriptIsAppliedRepeatedly()
{
// arrange
await using var direct = await fixture.CreateDatabaseAsync("ScriptParityDirect");
@@ -47,11 +46,11 @@ public async Task GenerateMigrationsSql_Should_MatchDirectMigration_When_Applied
var script = PostgresTransportSchema.GenerateMigrationsSql(options);
// act
- await fixture.ApplyScriptAsync(scripted.DatabaseName, script, CancellationToken);
- await fixture.ApplyScriptAsync(scripted.DatabaseName, script, CancellationToken);
+ await fixture.RunSqlScriptAsync(script, scripted.DatabaseName);
+ await fixture.RunSqlScriptAsync(script, scripted.DatabaseName);
await using var scriptedConnection = await OpenAsync(scripted.ConnectionString);
await PostgresTransportSchema.MigrateAsync(scriptedConnection, options, CancellationToken);
- await fixture.ApplyScriptAsync(direct.DatabaseName, script, CancellationToken);
+ await fixture.RunSqlScriptAsync(script, direct.DatabaseName);
// assert
Assert.Equal(await ReadSchemaAsync(connection), await ReadSchemaAsync(scriptedConnection));
@@ -83,8 +82,8 @@ INSERT INTO public.mocha_message (body, queue_id)
// act
if (script)
{
- await fixture.ApplyScriptAsync(database.DatabaseName,
- PostgresTransportSchema.GenerateMigrationsSql(options), CancellationToken);
+ await fixture.RunSqlScriptAsync(
+ PostgresTransportSchema.GenerateMigrationsSql(options), database.DatabaseName);
}
else
{
diff --git a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/__snapshots__/PostgresMigrationScriptTests.GenerateMigrationsSql_Should_ReturnDeterministicSql_When_UsingDefaults.snap b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/__snapshots__/PostgresMigrationScriptTests.GenerateMigrationsSql_Should_ReturnDeterministicSql_When_UsingDefaults.snap
index ab42bdbe1ee..3bca0e50f2e 100644
--- a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/__snapshots__/PostgresMigrationScriptTests.GenerateMigrationsSql_Should_ReturnDeterministicSql_When_UsingDefaults.snap
+++ b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/__snapshots__/PostgresMigrationScriptTests.GenerateMigrationsSql_Should_ReturnDeterministicSql_When_UsingDefaults.snap
@@ -1,8 +1,8 @@
BEGIN;
SELECT pg_advisory_xact_lock(958913715);
-CREATE SCHEMA IF NOT EXISTS "public";
+CREATE SCHEMA IF NOT EXISTS public;
-CREATE TABLE IF NOT EXISTS "public"."mocha_migrations"
+CREATE TABLE IF NOT EXISTS public.mocha_migrations
(
migration_id text NOT NULL PRIMARY KEY,
applied_on timestamptz NOT NULL DEFAULT (now() at time zone 'utc')
@@ -10,55 +10,55 @@ CREATE TABLE IF NOT EXISTS "public"."mocha_migrations"
DO $mocha_migration$
BEGIN
- IF NOT EXISTS (SELECT 1 FROM "public"."mocha_migrations" WHERE migration_id = E'2026-03-06_InitialSchema') THEN
- CREATE SCHEMA IF NOT EXISTS "public";
+ IF NOT EXISTS (SELECT 1 FROM public.mocha_migrations WHERE migration_id = E'2026-03-06_InitialSchema') THEN
+ CREATE SCHEMA IF NOT EXISTS public;
- CREATE SEQUENCE IF NOT EXISTS "public"."mocha_topology_seq" AS bigint;
+ CREATE SEQUENCE IF NOT EXISTS public.mocha_topology_seq AS bigint;
- CREATE TABLE IF NOT EXISTS "public"."mocha_topic"
+ CREATE TABLE IF NOT EXISTS public.mocha_topic
(
- id bigint NOT NULL PRIMARY KEY DEFAULT nextval(E'"public"."mocha_topology_seq"'),
+ id bigint NOT NULL PRIMARY KEY DEFAULT nextval(E'public.mocha_topology_seq'),
updated timestamptz NOT NULL DEFAULT (now() at time zone 'utc'),
name text NOT NULL
);
- CREATE UNIQUE INDEX IF NOT EXISTS "mocha_topic_uqx" ON "public"."mocha_topic" (name) INCLUDE (id);
- ALTER TABLE "public"."mocha_topic" ADD CONSTRAINT "mocha_unique_topic" UNIQUE USING INDEX "mocha_topic_uqx";
+ CREATE UNIQUE INDEX IF NOT EXISTS mocha_topic_uqx ON public.mocha_topic (name) INCLUDE (id);
+ ALTER TABLE public.mocha_topic ADD CONSTRAINT mocha_unique_topic UNIQUE USING INDEX mocha_topic_uqx;
- CREATE TABLE IF NOT EXISTS "public"."mocha_queue"
+ CREATE TABLE IF NOT EXISTS public.mocha_queue
(
- id bigint NOT NULL PRIMARY KEY DEFAULT nextval(E'"public"."mocha_topology_seq"'),
+ id bigint NOT NULL PRIMARY KEY DEFAULT nextval(E'public.mocha_topology_seq'),
updated timestamptz NOT NULL DEFAULT (now() at time zone 'utc'),
name text NOT NULL
);
- CREATE UNIQUE INDEX IF NOT EXISTS "mocha_queue_uqx" ON "public"."mocha_queue" (name) INCLUDE (id);
- ALTER TABLE "public"."mocha_queue" ADD CONSTRAINT "mocha_unique_queue" UNIQUE USING INDEX "mocha_queue_uqx";
+ CREATE UNIQUE INDEX IF NOT EXISTS mocha_queue_uqx ON public.mocha_queue (name) INCLUDE (id);
+ ALTER TABLE public.mocha_queue ADD CONSTRAINT mocha_unique_queue UNIQUE USING INDEX mocha_queue_uqx;
- CREATE TABLE IF NOT EXISTS "public"."mocha_queue_subscription"
+ CREATE TABLE IF NOT EXISTS public.mocha_queue_subscription
(
- id bigint NOT NULL PRIMARY KEY DEFAULT nextval(E'"public"."mocha_topology_seq"'),
+ id bigint NOT NULL PRIMARY KEY DEFAULT nextval(E'public.mocha_topology_seq'),
updated timestamptz NOT NULL DEFAULT (now() at time zone 'utc'),
- source_id bigint NOT NULL REFERENCES "public"."mocha_topic" (id) ON DELETE CASCADE,
- destination_id bigint NOT NULL REFERENCES "public"."mocha_queue" (id) ON DELETE CASCADE
+ source_id bigint NOT NULL REFERENCES public.mocha_topic (id) ON DELETE CASCADE,
+ destination_id bigint NOT NULL REFERENCES public.mocha_queue (id) ON DELETE CASCADE
);
- CREATE UNIQUE INDEX IF NOT EXISTS "mocha_queue_subscription_uqx"
- ON "public"."mocha_queue_subscription" (source_id, destination_id);
- ALTER TABLE "public"."mocha_queue_subscription"
- ADD CONSTRAINT "mocha_unique_queue_subscription" UNIQUE USING INDEX "mocha_queue_subscription_uqx";
+ CREATE UNIQUE INDEX IF NOT EXISTS mocha_queue_subscription_uqx
+ ON public.mocha_queue_subscription (source_id, destination_id);
+ ALTER TABLE public.mocha_queue_subscription
+ ADD CONSTRAINT mocha_unique_queue_subscription UNIQUE USING INDEX mocha_queue_subscription_uqx;
- CREATE INDEX IF NOT EXISTS "mocha_queue_subscription_source_ndx"
- ON "public"."mocha_queue_subscription" (source_id) INCLUDE (id, destination_id);
- CREATE INDEX IF NOT EXISTS "mocha_queue_subscription_dest_ndx"
- ON "public"."mocha_queue_subscription" (destination_id) INCLUDE (id, source_id);
+ CREATE INDEX IF NOT EXISTS mocha_queue_subscription_source_ndx
+ ON public.mocha_queue_subscription (source_id) INCLUDE (id, destination_id);
+ CREATE INDEX IF NOT EXISTS mocha_queue_subscription_dest_ndx
+ ON public.mocha_queue_subscription (destination_id) INCLUDE (id, source_id);
- CREATE TABLE IF NOT EXISTS "public"."mocha_message"
+ CREATE TABLE IF NOT EXISTS public.mocha_message
(
transport_message_id uuid NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
body bytea NOT NULL,
headers jsonb,
- queue_id bigint NOT NULL REFERENCES "public"."mocha_queue" (id) ON DELETE CASCADE,
+ queue_id bigint NOT NULL REFERENCES public.mocha_queue (id) ON DELETE CASCADE,
sent_time timestamptz NOT NULL DEFAULT (now() at time zone 'utc'),
scheduled_time timestamptz,
expiration_time timestamptz,
@@ -69,33 +69,33 @@ BEGIN
error_reason jsonb
);
- CREATE INDEX IF NOT EXISTS "mocha_message_queue_ndx"
- ON "public"."mocha_message" (queue_id) INCLUDE (transport_message_id);
- INSERT INTO "public"."mocha_migrations" (migration_id) VALUES (E'2026-03-06_InitialSchema');
+ CREATE INDEX IF NOT EXISTS mocha_message_queue_ndx
+ ON public.mocha_message (queue_id) INCLUDE (transport_message_id);
+ INSERT INTO public.mocha_migrations (migration_id) VALUES (E'2026-03-06_InitialSchema');
END IF;
END;
$mocha_migration$;
DO $mocha_migration$
BEGIN
- IF NOT EXISTS (SELECT 1 FROM "public"."mocha_migrations" WHERE migration_id = E'2026-03-06_AddTransportIndex') THEN
- CREATE INDEX IF NOT EXISTS "mocha_message_transport_queue_ndx"
- ON "public"."mocha_message" (transport_message_id, queue_id);
+ IF NOT EXISTS (SELECT 1 FROM public.mocha_migrations WHERE migration_id = E'2026-03-06_AddTransportIndex') THEN
+ CREATE INDEX IF NOT EXISTS mocha_message_transport_queue_ndx
+ ON public.mocha_message (transport_message_id, queue_id);
- CREATE INDEX IF NOT EXISTS "mocha_message_expiration_scheduled_ndx"
- ON "public"."mocha_message" (expiration_time, scheduled_time);
+ CREATE INDEX IF NOT EXISTS mocha_message_expiration_scheduled_ndx
+ ON public.mocha_message (expiration_time, scheduled_time);
- CREATE INDEX IF NOT EXISTS "mocha_message_sent_time_ndx"
- ON "public"."mocha_message" (sent_time);
- INSERT INTO "public"."mocha_migrations" (migration_id) VALUES (E'2026-03-06_AddTransportIndex');
+ CREATE INDEX IF NOT EXISTS mocha_message_sent_time_ndx
+ ON public.mocha_message (sent_time);
+ INSERT INTO public.mocha_migrations (migration_id) VALUES (E'2026-03-06_AddTransportIndex');
END IF;
END;
$mocha_migration$;
DO $mocha_migration$
BEGIN
- IF NOT EXISTS (SELECT 1 FROM "public"."mocha_migrations" WHERE migration_id = E'2026-03-06_AddConsumerManagement') THEN
- CREATE TABLE IF NOT EXISTS "public"."mocha_consumers"
+ IF NOT EXISTS (SELECT 1 FROM public.mocha_migrations WHERE migration_id = E'2026-03-06_AddConsumerManagement') THEN
+ CREATE TABLE IF NOT EXISTS public.mocha_consumers
(
id uuid NOT NULL PRIMARY KEY,
service_name text NOT NULL,
@@ -103,15 +103,15 @@ BEGIN
updated_at timestamptz NOT NULL DEFAULT now()
);
- ALTER TABLE "public"."mocha_queue"
- ADD COLUMN IF NOT EXISTS consumer_id uuid REFERENCES "public"."mocha_consumers"(id) ON DELETE CASCADE;
+ ALTER TABLE public.mocha_queue
+ ADD COLUMN IF NOT EXISTS consumer_id uuid REFERENCES public.mocha_consumers(id) ON DELETE CASCADE;
- CREATE INDEX IF NOT EXISTS "mocha_consumers_updated_at_ndx"
- ON "public"."mocha_consumers" (updated_at);
+ CREATE INDEX IF NOT EXISTS mocha_consumers_updated_at_ndx
+ ON public.mocha_consumers (updated_at);
- CREATE INDEX IF NOT EXISTS "mocha_queue_consumer_id_ndx"
- ON "public"."mocha_queue" (consumer_id);
- INSERT INTO "public"."mocha_migrations" (migration_id) VALUES (E'2026-03-06_AddConsumerManagement');
+ CREATE INDEX IF NOT EXISTS mocha_queue_consumer_id_ndx
+ ON public.mocha_queue (consumer_id);
+ INSERT INTO public.mocha_migrations (migration_id) VALUES (E'2026-03-06_AddConsumerManagement');
END IF;
END;
$mocha_migration$;
diff --git a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/__snapshots__/PostgresTransportSchemaTests.MigrateAsync_Should_CreateTransportSchema_When_DatabaseIsEmpty.snap b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/__snapshots__/PostgresTransportSchemaTests.MigrateAsync_Should_CreateTransportSchema_When_DatabaseIsEmpty.snap
deleted file mode 100644
index 383f3c5385a..00000000000
--- a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/__snapshots__/PostgresTransportSchemaTests.MigrateAsync_Should_CreateTransportSchema_When_DatabaseIsEmpty.snap
+++ /dev/null
@@ -1,85 +0,0 @@
-[
- "column: mocha_consumers.created_at timestamp with time zone NO",
- "column: mocha_consumers.id uuid NO",
- "column: mocha_consumers.service_name text NO",
- "column: mocha_consumers.updated_at timestamp with time zone NO",
- "column: mocha_message.body bytea NO",
- "column: mocha_message.consumer_id uuid YES",
- "column: mocha_message.delivery_count integer NO",
- "column: mocha_message.error_reason jsonb YES",
- "column: mocha_message.expiration_time timestamp with time zone YES",
- "column: mocha_message.headers jsonb YES",
- "column: mocha_message.last_delivered timestamp with time zone YES",
- "column: mocha_message.max_delivery_count integer NO",
- "column: mocha_message.queue_id bigint NO",
- "column: mocha_message.scheduled_time timestamp with time zone YES",
- "column: mocha_message.sent_time timestamp with time zone NO",
- "column: mocha_message.transport_message_id uuid NO",
- "column: mocha_migrations.applied_on timestamp with time zone NO",
- "column: mocha_migrations.migration_id text NO",
- "column: mocha_queue.consumer_id uuid YES",
- "column: mocha_queue.id bigint NO",
- "column: mocha_queue.name text NO",
- "column: mocha_queue.updated timestamp with time zone NO",
- "column: mocha_queue_subscription.destination_id bigint NO",
- "column: mocha_queue_subscription.id bigint NO",
- "column: mocha_queue_subscription.source_id bigint NO",
- "column: mocha_queue_subscription.updated timestamp with time zone NO",
- "column: mocha_topic.id bigint NO",
- "column: mocha_topic.name text NO",
- "column: mocha_topic.updated timestamp with time zone NO",
- "constraint: mocha_consumers_pkey PRIMARY KEY (id)",
- "constraint: mocha_message_pkey PRIMARY KEY (transport_message_id)",
- "constraint: mocha_message_queue_id_fkey FOREIGN KEY (queue_id) REFERENCES mocha_queue(id) ON DELETE CASCADE",
- "constraint: mocha_migrations_pkey PRIMARY KEY (migration_id)",
- "constraint: mocha_queue_consumer_id_fkey FOREIGN KEY (consumer_id) REFERENCES mocha_consumers(id) ON DELETE CASCADE",
- "constraint: mocha_queue_pkey PRIMARY KEY (id)",
- "constraint: mocha_queue_subscription_destination_id_fkey FOREIGN KEY (destination_id) REFERENCES mocha_queue(id) ON DELETE CASCADE",
- "constraint: mocha_queue_subscription_pkey PRIMARY KEY (id)",
- "constraint: mocha_queue_subscription_source_id_fkey FOREIGN KEY (source_id) REFERENCES mocha_topic(id) ON DELETE CASCADE",
- "constraint: mocha_topic_pkey PRIMARY KEY (id)",
- "constraint: mocha_unique_queue UNIQUE (name) INCLUDE (id)",
- "constraint: mocha_unique_queue_subscription UNIQUE (source_id, destination_id)",
- "constraint: mocha_unique_topic UNIQUE (name) INCLUDE (id)",
- "index: CREATE INDEX mocha_consumers_updated_at_ndx ON public.mocha_consumers USING btree (updated_at)",
- "index: CREATE INDEX mocha_message_expiration_scheduled_ndx ON public.mocha_message USING btree (expiration_time, scheduled_time)",
- "index: CREATE INDEX mocha_message_queue_ndx ON public.mocha_message USING btree (queue_id) INCLUDE (transport_message_id)",
- "index: CREATE INDEX mocha_message_sent_time_ndx ON public.mocha_message USING btree (sent_time)",
- "index: CREATE INDEX mocha_message_transport_queue_ndx ON public.mocha_message USING btree (transport_message_id, queue_id)",
- "index: CREATE INDEX mocha_queue_consumer_id_ndx ON public.mocha_queue USING btree (consumer_id)",
- "index: CREATE INDEX mocha_queue_subscription_dest_ndx ON public.mocha_queue_subscription USING btree (destination_id) INCLUDE (id, source_id)",
- "index: CREATE INDEX mocha_queue_subscription_source_ndx ON public.mocha_queue_subscription USING btree (source_id) INCLUDE (id, destination_id)",
- "index: CREATE UNIQUE INDEX mocha_consumers_pkey ON public.mocha_consumers USING btree (id)",
- "index: CREATE UNIQUE INDEX mocha_message_pkey ON public.mocha_message USING btree (transport_message_id)",
- "index: CREATE UNIQUE INDEX mocha_migrations_pkey ON public.mocha_migrations USING btree (migration_id)",
- "index: CREATE UNIQUE INDEX mocha_queue_pkey ON public.mocha_queue USING btree (id)",
- "index: CREATE UNIQUE INDEX mocha_queue_subscription_pkey ON public.mocha_queue_subscription USING btree (id)",
- "index: CREATE UNIQUE INDEX mocha_topic_pkey ON public.mocha_topic USING btree (id)",
- "index: CREATE UNIQUE INDEX mocha_unique_queue ON public.mocha_queue USING btree (name) INCLUDE (id)",
- "index: CREATE UNIQUE INDEX mocha_unique_queue_subscription ON public.mocha_queue_subscription USING btree (source_id, destination_id)",
- "index: CREATE UNIQUE INDEX mocha_unique_topic ON public.mocha_topic USING btree (name) INCLUDE (id)",
- "relation: mocha_consumers r",
- "relation: mocha_consumers_pkey i",
- "relation: mocha_consumers_updated_at_ndx i",
- "relation: mocha_message r",
- "relation: mocha_message_expiration_scheduled_ndx i",
- "relation: mocha_message_pkey i",
- "relation: mocha_message_queue_ndx i",
- "relation: mocha_message_sent_time_ndx i",
- "relation: mocha_message_transport_queue_ndx i",
- "relation: mocha_migrations r",
- "relation: mocha_migrations_pkey i",
- "relation: mocha_queue r",
- "relation: mocha_queue_consumer_id_ndx i",
- "relation: mocha_queue_pkey i",
- "relation: mocha_queue_subscription r",
- "relation: mocha_queue_subscription_dest_ndx i",
- "relation: mocha_queue_subscription_pkey i",
- "relation: mocha_queue_subscription_source_ndx i",
- "relation: mocha_topic r",
- "relation: mocha_topic_pkey i",
- "relation: mocha_topology_seq S",
- "relation: mocha_unique_queue i",
- "relation: mocha_unique_queue_subscription i",
- "relation: mocha_unique_topic i"
-]
diff --git a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Helpers/PostgresFixture.cs b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Helpers/PostgresFixture.cs
index be40166e737..28558bac882 100644
--- a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Helpers/PostgresFixture.cs
+++ b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Helpers/PostgresFixture.cs
@@ -8,7 +8,7 @@ namespace Mocha.Transport.Postgres.Tests.Helpers;
public sealed class PostgresFixture : IAsyncLifetime
{
- private readonly ScriptPostgreSqlResource _resource = new();
+ private readonly PostgreSqlResource _resource = new();
public async ValueTask InitializeAsync()
{
@@ -22,8 +22,8 @@ public async ValueTask DisposeAsync()
public string ConnectionString => _resource.ConnectionString;
- public Task ApplyScriptAsync(string database, string sql, CancellationToken cancellationToken)
- => _resource.ApplyScriptAsync(database, sql, cancellationToken);
+ public Task RunSqlScriptAsync(string sql, string database)
+ => _resource.RunSqlScriptAsync(sql, database);
///
/// Creates an isolated database for each test to avoid interference.
@@ -77,20 +77,6 @@ private static string GenerateDatabaseName(string testName, string filePath)
: testName;
return $"mocha_{truncatedName}_{hash}".ToLowerInvariant();
}
-
- private sealed class ScriptPostgreSqlResource : PostgreSqlResource
- {
- public async Task ApplyScriptAsync(string database, string sql, CancellationToken cancellationToken)
- {
- var path = $"/tmp/mocha-migration-{Guid.NewGuid():N}.sql";
- await Container.CopyAsync(Encoding.UTF8.GetBytes(sql), path, ct: cancellationToken);
- var result = await Container.ExecAsync(
- ["psql", "--no-psqlrc", "-U", "postgres", "-d", database,
- "--set=ON_ERROR_STOP=1", "--file=" + path],
- cancellationToken);
- Assert.True(result.ExitCode == 0, result.Stderr);
- }
- }
}
public sealed class DatabaseContext(PostgresFixture fixture, string databaseName, string connectionString) : IAsyncDisposable
From 476b300840b397c30f3b7cea7ffdc4983f2a273a Mon Sep 17 00:00:00 2001
From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com>
Date: Tue, 6 Oct 2026 12:21:01 +0000
Subject: [PATCH 4/8] Cleanup
---
.../Connection/PostgresSchemaSql.cs | 6 +-
.../PostgresMigrationOutboxTests.cs | 100 ++---
.../Behaviors/AutoMigrateIntegrationTests.cs | 238 ++++++------
.../PostgresMigrationScriptTests.cs | 3 +-
.../PostgresTransportSchemaTests.cs | 354 ++++++++++++------
.../Descriptors/PostgresDescriptorTests.cs | 49 +--
6 files changed, 453 insertions(+), 297 deletions(-)
diff --git a/src/Mocha/src/Mocha.Transport.Postgres/Connection/PostgresSchemaSql.cs b/src/Mocha/src/Mocha.Transport.Postgres/Connection/PostgresSchemaSql.cs
index 126e7f8f1cd..028c3530a43 100644
--- a/src/Mocha/src/Mocha.Transport.Postgres/Connection/PostgresSchemaSql.cs
+++ b/src/Mocha/src/Mocha.Transport.Postgres/Connection/PostgresSchemaSql.cs
@@ -37,7 +37,8 @@ private static string DoBlock(string body)
var suffix = 0;
while (body.Contains(delimiter, StringComparison.Ordinal))
{
- delimiter = $"$mocha_migration_{++suffix}$";
+ suffix++;
+ delimiter = $"$mocha_migration_{suffix}$";
}
return $"""
@@ -50,8 +51,7 @@ private static string DoBlock(string body)
private static string Indent(string sql, int spaces)
{
var indentation = new string(' ', spaces);
- return string.Join("\n", sql.ReplaceLineEndings("\n").Split('\n')
- .Select(line => line.Length == 0 ? line : indentation + line));
+ return indentation + sql.ReplaceLineEndings("\n" + indentation);
}
private static string Literal(string value)
diff --git a/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/PostgresMigrationOutboxTests.cs b/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/PostgresMigrationOutboxTests.cs
index 7477a36349b..455b8153336 100644
--- a/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/PostgresMigrationOutboxTests.cs
+++ b/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/PostgresMigrationOutboxTests.cs
@@ -13,71 +13,54 @@ public sealed class PostgresMigrationOutboxTests(PostgresFixture fixture) : ICla
[Theory]
[InlineData(false)]
[InlineData(true)]
- public async Task PublishAsync_Should_PersistOutboxMessage_When_DatabasePreparedBeforeRuntime(bool template)
+ public async Task PublishAsync_Should_PersistOutboxMessage_When_DatabasePreparedBeforeRuntime(bool cloneFromTemplate)
{
// arrange
- var ct = TestContext.Current.CancellationToken;
- var source = new NpgsqlConnectionStringBuilder(await fixture.CreateDatabaseAsync()) { Pooling = false };
- var options = new DbContextOptionsBuilder().UseTestNpgsql(source.ConnectionString).Options;
- await using (var db = new TestDbContext(options))
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var connectionString = await CreateMigratedDatabaseAsync(cancellationToken);
+ if (cloneFromTemplate)
{
- await db.Database.EnsureCreatedAsync(ct);
- await db.Database.OpenConnectionAsync(ct);
- await PostgresTransportSchema.MigrateAsync(
- (NpgsqlConnection)db.Database.GetDbConnection(), new PostgresSchemaOptions(), ct);
- }
-
- var runtimeConnection = source.ConnectionString;
- if (template)
- {
- var clone = new NpgsqlConnectionStringBuilder(await fixture.CreateDatabaseAsync()) { Pooling = false };
- var adminSettings = new NpgsqlConnectionStringBuilder(source.ConnectionString) { Database = "postgres" };
- await using var admin = new NpgsqlConnection(adminSettings.ConnectionString);
- await admin.OpenAsync(ct);
- await using var command = admin.CreateCommand();
- command.CommandText = $"DROP DATABASE \"{clone.Database}\";";
- await command.ExecuteNonQueryAsync(ct);
- command.CommandText = $"CREATE DATABASE \"{clone.Database}\" TEMPLATE \"{source.Database}\";";
- await command.ExecuteNonQueryAsync(ct);
- runtimeConnection = clone.ConnectionString;
+ connectionString = await CloneDatabaseAsync(connectionString, cancellationToken);
}
var services = new ServiceCollection();
- services.AddDbContext(o => o.UseTestNpgsql(runtimeConnection));
+ services.AddDbContext(options => options.UseTestNpgsql(connectionString));
services.AddSingleton();
services.AddMessageBus()
.AddEntityFramework(ef => ef.UsePostgresOutbox())
- .AddPostgres(t =>
+ .AddPostgres(transport =>
{
- t.ConnectionString(runtimeConnection).AutoMigrate(false).AutoProvision(true);
- t.DeclareTopic("prepared-events");
- t.DispatchEndpoint("prepared-events").ToTopic("prepared-events").Publish();
+ transport.ConnectionString(connectionString);
+ transport.AutoMigrate(false);
+ transport.DeclareTopic("prepared-events");
+ transport.DispatchEndpoint("prepared-events").ToTopic("prepared-events").Publish();
});
+
await using var provider = services.BuildServiceProvider();
var runtime = (MessagingRuntime)provider.GetRequiredService();
await using var transport = runtime.Transports.OfType().Single();
- await runtime.StartAsync(ct);
+ var expectedEvent = new PreparedEvent("persisted-before-dispatch");
+ await runtime.StartAsync(cancellationToken);
+
try
{
// act
- using (var scope = provider.CreateScope())
+ await using (var scope = provider.CreateAsyncScope())
{
- var bus = scope.ServiceProvider.GetRequiredService();
- await bus.PublishAsync(new PreparedEvent("persisted-before-dispatch"), ct);
+ var messageBus = scope.ServiceProvider.GetRequiredService();
+ await messageBus.PublishAsync(expectedEvent, cancellationToken);
}
// assert
- using var verification = provider.CreateScope();
- var db = verification.ServiceProvider.GetRequiredService();
- var message = await db.Set().AsNoTracking().SingleAsync(ct);
+ await using var verification = provider.CreateAsyncScope();
+ var context = verification.ServiceProvider.GetRequiredService();
+ var message = await context.Set().AsNoTracking().SingleAsync(cancellationToken);
var body = message.Envelope.RootElement.GetProperty("body");
- var persisted = body.Deserialize(new JsonSerializerOptions(JsonSerializerDefaults.Web));
- Assert.Equal(new PreparedEvent("persisted-before-dispatch"), persisted);
+ var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
+ var persistedEvent = body.Deserialize(jsonOptions);
+
+ Assert.Equal(expectedEvent, persistedEvent);
Assert.Equal(0, message.TimesSent);
- await db.Database.OpenConnectionAsync(ct);
- await using var command = db.Database.GetDbConnection().CreateCommand();
- command.CommandText = "SELECT count(*) FROM public.mocha_migrations;";
- Assert.Equal(3L, await command.ExecuteScalarAsync(ct));
}
finally
{
@@ -85,5 +68,38 @@ await PostgresTransportSchema.MigrateAsync(
}
}
+ private async Task CreateMigratedDatabaseAsync(CancellationToken cancellationToken)
+ {
+ var connectionString = await fixture.CreateDatabaseAsync();
+ var settings = new NpgsqlConnectionStringBuilder(connectionString) { Pooling = false };
+ var options = new DbContextOptionsBuilder()
+ .UseTestNpgsql(settings.ConnectionString)
+ .Options;
+ await using var context = new TestDbContext(options);
+ await context.Database.EnsureCreatedAsync(cancellationToken);
+ await context.Database.OpenConnectionAsync(cancellationToken);
+ var connection = (NpgsqlConnection)context.Database.GetDbConnection();
+ await PostgresTransportSchema.MigrateAsync(connection, new PostgresSchemaOptions(), cancellationToken);
+
+ return settings.ConnectionString;
+ }
+
+ private static async Task CloneDatabaseAsync(
+ string templateConnectionString,
+ CancellationToken cancellationToken)
+ {
+ var template = new NpgsqlConnectionStringBuilder(templateConnectionString);
+ var cloneName = $"mocha_clone_{Guid.NewGuid():N}";
+ var adminSettings = new NpgsqlConnectionStringBuilder(templateConnectionString) { Database = "postgres" };
+ await using var connection = new NpgsqlConnection(adminSettings.ConnectionString);
+ await connection.OpenAsync(cancellationToken);
+ await using var command = connection.CreateCommand();
+ command.CommandText = $"CREATE DATABASE \"{cloneName}\" TEMPLATE \"{template.Database}\";";
+ await command.ExecuteNonQueryAsync(cancellationToken);
+
+ var cloneSettings = new NpgsqlConnectionStringBuilder(templateConnectionString) { Database = cloneName };
+ return cloneSettings.ConnectionString;
+ }
+
public sealed record PreparedEvent(string Payload);
}
diff --git a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Behaviors/AutoMigrateIntegrationTests.cs b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Behaviors/AutoMigrateIntegrationTests.cs
index b5ae9f08438..c83c2a4c7d9 100644
--- a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Behaviors/AutoMigrateIntegrationTests.cs
+++ b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Behaviors/AutoMigrateIntegrationTests.cs
@@ -1,6 +1,3 @@
-using System.Collections.Concurrent;
-using System.Diagnostics;
-using CookieCrumble;
using Microsoft.Extensions.DependencyInjection;
using Mocha.Transport.Postgres.Tests.Helpers;
using Npgsql;
@@ -10,126 +7,137 @@ namespace Mocha.Transport.Postgres.Tests.Behaviors;
[Collection("Postgres")]
public class AutoMigrateIntegrationTests(PostgresFixture fixture)
{
+ private static readonly TimeSpan s_timeout = TimeSpan.FromSeconds(30);
private static CancellationToken CancellationToken => TestContext.Current.CancellationToken;
[Theory]
- [InlineData(null, false)]
- [InlineData(true, false)]
- [InlineData(null, true)]
- [InlineData(true, true)]
- public async Task StartAsync_Should_Migrate_When_AutoMigrateEnabled(bool? autoMigrate, bool upgrade)
+ [InlineData(null)]
+ [InlineData(true)]
+ public async Task StartAsync_Should_CreateSchema_When_AutoMigrateEnabled(bool? autoMigrate)
{
// arrange
await using var database = await fixture.CreateDatabaseAsync();
await using var connection = CreateSetupConnection(database.ConnectionString);
await connection.OpenAsync(CancellationToken);
- if (upgrade)
- {
- await ExecuteAsync(connection, PostgresSchemaSql.InitialSchema(new PostgresSchemaOptions()));
- await ExecuteAsync(connection,
- """
- CREATE TABLE public.mocha_migrations (
- migration_id text PRIMARY KEY,
- applied_on timestamptz NOT NULL DEFAULT now());
- INSERT INTO public.mocha_migrations (migration_id) VALUES ('2026-03-06_InitialSchema');
- """);
- }
-
var services = new ServiceCollection();
- services.AddMessageBus().AddPostgres(t =>
+ services.AddMessageBus().AddPostgres(transport =>
{
- t.ConnectionString(database.ConnectionString).AutoProvision(false);
+ transport.ConnectionString(database.ConnectionString);
+ transport.AutoProvision(false);
+
if (autoMigrate.HasValue)
{
- t.AutoMigrate(autoMigrate.Value);
+ transport.AutoMigrate(autoMigrate.Value);
}
});
- await using var provider = services.BuildServiceProvider();
+
+ var provider = services.BuildServiceProvider();
var runtime = (MessagingRuntime)provider.GetRequiredService();
await using var transport = runtime.Transports.OfType().Single();
+ await using var bus = new TestBus(provider, runtime);
// act
await runtime.StartAsync(CancellationToken);
- try
- {
- // assert
- Assert.Equal(3L, await ScalarAsync(connection, "SELECT count(*) FROM public.mocha_migrations;"));
- Assert.Equal(1L, await ScalarAsync(connection, "SELECT count(*) FROM public.mocha_consumers;"));
- }
- finally
- {
- await transport.StopAsync(runtime, CancellationToken.None);
- }
+
+ // assert
+ var migrationCount = await ExecuteScalarAsync(connection,
+ "SELECT count(*) FROM public.mocha_migrations;");
+ var consumerCount = await ExecuteScalarAsync(connection,
+ "SELECT count(*) FROM public.mocha_consumers;");
+
+ Assert.Equal(3, migrationCount);
+ Assert.Equal(1, consumerCount);
}
[Fact]
- public async Task StartAsync_Should_SkipSchemaWork_When_PrivilegedRuntimeDisablesMigration()
+ public async Task StartAsync_Should_UpgradeSchema_When_InitialMigrationAlreadyApplied()
{
// arrange
await using var database = await fixture.CreateDatabaseAsync();
await using var connection = CreateSetupConnection(database.ConnectionString);
await connection.OpenAsync(CancellationToken);
- await PostgresTransportSchema.MigrateAsync(connection, new PostgresSchemaOptions(), CancellationToken);
+ await ExecuteAsync(connection, PostgresSchemaSql.InitialSchema(new PostgresSchemaOptions()));
await ExecuteAsync(connection,
"""
- ALTER TABLE public.mocha_migrations RENAME TO saved_migrations;
- CREATE FUNCTION public.reject_history_read() RETURNS text AS $$
- BEGIN
- RAISE EXCEPTION 'Migration history must not be read';
- END;
- $$ LANGUAGE plpgsql;
- CREATE VIEW public.mocha_migrations AS
- SELECT public.reject_history_read() AS migration_id;
- CREATE FUNCTION public.reject_ddl() RETURNS event_trigger AS $$
- BEGIN
- RAISE EXCEPTION 'Schema DDL must not run';
- END;
- $$ LANGUAGE plpgsql;
- CREATE EVENT TRIGGER reject_ddl ON ddl_command_start EXECUTE FUNCTION public.reject_ddl();
+ CREATE TABLE public.mocha_migrations (
+ migration_id text PRIMARY KEY,
+ applied_on timestamptz NOT NULL DEFAULT now());
+ INSERT INTO public.mocha_migrations (migration_id) VALUES ('2026-03-06_InitialSchema');
""");
- var statements = new ConcurrentBag();
- using var listener = new ActivityListener
+
+ var services = new ServiceCollection();
+ services.AddMessageBus().AddPostgres(transport =>
{
- ShouldListenTo = source => source.Name == "Npgsql",
- Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded,
- ActivityStopped = activity =>
- {
- if (activity.GetTagItem("db.statement") is string sql)
- {
- statements.Add(sql);
- }
- }
- };
- ActivitySource.AddActivityListener(listener);
+ transport.ConnectionString(database.ConnectionString);
+ transport.AutoProvision(false);
+ });
+
+ var provider = services.BuildServiceProvider();
+ var runtime = (MessagingRuntime)provider.GetRequiredService();
+ await using var transport = runtime.Transports.OfType().Single();
+ await using var bus = new TestBus(provider, runtime);
// act
- await PublishAndReceiveAsync(database.ConnectionString);
- listener.Dispose();
+ await runtime.StartAsync(CancellationToken);
// assert
- Assert.Contains(statements, sql => sql.Contains("INSERT INTO public.mocha_consumers", StringComparison.Ordinal));
- statements.Where(sql =>
- sql.Contains("mocha_migrations", StringComparison.OrdinalIgnoreCase)
- || sql.Contains("pg_catalog", StringComparison.OrdinalIgnoreCase)
- || sql.Contains("information_schema", StringComparison.OrdinalIgnoreCase)
- || sql.Contains("pg_tables", StringComparison.OrdinalIgnoreCase)
- || sql.Contains("to_regclass", StringComparison.OrdinalIgnoreCase)
- || sql.Contains("pg_advisory", StringComparison.OrdinalIgnoreCase))
- .ToArray().MatchInlineSnapshot("[]");
- Assert.Equal(3L, await ScalarAsync(connection, "SELECT count(*) FROM public.saved_migrations;"));
- Assert.True((long)(await ScalarAsync(connection, "SELECT count(*) FROM public.mocha_queue;"))! > 0);
+ var migrationCount = await ExecuteScalarAsync(connection,
+ "SELECT count(*) FROM public.mocha_migrations;");
+ var consumerCount = await ExecuteScalarAsync(connection,
+ "SELECT count(*) FROM public.mocha_consumers;");
+
+ Assert.Equal(3, migrationCount);
+ Assert.Equal(1, consumerCount);
+ }
+
+ [Fact]
+ public async Task PublishAsync_Should_DeliverWithoutMigrationHistory_When_AutoMigrateDisabled()
+ {
+ // arrange
+ var recorder = new MessageRecorder();
+ await using var database = await fixture.CreateDatabaseAsync();
+ await using var connection = CreateSetupConnection(database.ConnectionString);
+ await connection.OpenAsync(CancellationToken);
+ await PostgresTransportSchema.MigrateAsync(connection, new PostgresSchemaOptions(), CancellationToken);
+ await ExecuteAsync(connection, "DROP TABLE public.mocha_migrations;");
+
+ await using var bus = await new ServiceCollection()
+ .AddSingleton(recorder)
+ .AddMessageBus()
+ .AddEventHandler()
+ .AddPostgres(transport => transport.ConnectionString(database.ConnectionString).AutoMigrate(false))
+ .BuildTestBusAsync();
+
+ using var scope = bus.Provider.CreateScope();
+ var messageBus = scope.ServiceProvider.GetRequiredService();
+
+ // act
+ await messageBus.PublishAsync(new OrderCreated { OrderId = "ORD-1" }, CancellationToken);
+ var delivered = await recorder.WaitAsync(s_timeout);
+
+ // assert
+ var historyExists = await ExecuteScalarAsync(connection,
+ "SELECT to_regclass('public.mocha_migrations') IS NOT NULL;");
+
+ Assert.True(delivered, "Handler did not receive the event.");
+ var message = Assert.Single(recorder.Messages);
+ var order = Assert.IsType(message);
+ Assert.Equal("ORD-1", order.OrderId);
+ Assert.False(historyExists);
}
[Fact]
public async Task PublishAsync_Should_Deliver_When_RuntimeHasOnlyDmlPermissions()
{
// arrange
+ var recorder = new MessageRecorder();
await using var database = await fixture.CreateDatabaseAsync();
await using var connection = CreateSetupConnection(database.ConnectionString);
await connection.OpenAsync(CancellationToken);
await PostgresTransportSchema.MigrateAsync(connection, new PostgresSchemaOptions(), CancellationToken);
- var role = "mocha_runtime_" + Guid.NewGuid().ToString("N");
+ var role = $"mocha_runtime_{Guid.NewGuid():N}";
await ExecuteAsync(connection, $"CREATE ROLE {role} LOGIN PASSWORD 'runtime-test';");
+
try
{
await ExecuteAsync(connection,
@@ -147,15 +155,31 @@ await ExecuteAsync(connection,
Password = "runtime-test",
Pooling = false
};
+ await using var bus = await new ServiceCollection()
+ .AddSingleton(recorder)
+ .AddMessageBus()
+ .AddEventHandler()
+ .AddPostgres(transport => transport.ConnectionString(settings.ConnectionString).AutoMigrate(false))
+ .BuildTestBusAsync();
+
+ using var scope = bus.Provider.CreateScope();
+ var messageBus = scope.ServiceProvider.GetRequiredService();
// act
- await PublishAndReceiveAsync(settings.ConnectionString);
+ await messageBus.PublishAsync(new OrderCreated { OrderId = "ORD-1" }, CancellationToken);
+ var delivered = await recorder.WaitAsync(s_timeout);
// assert
- Assert.Equal(false, await ScalarAsync(connection,
- $"SELECT has_schema_privilege('{role}', 'public', 'CREATE');"));
- Assert.Equal(false, await ScalarAsync(connection,
- $"SELECT has_table_privilege('{role}', 'public.mocha_migrations', 'SELECT');"));
+ var canCreateSchema = await ExecuteScalarAsync(connection,
+ $"SELECT has_schema_privilege('{role}', 'public', 'CREATE');");
+ var canReadHistory = await ExecuteScalarAsync(connection,
+ $"SELECT has_table_privilege('{role}', 'public.mocha_migrations', 'SELECT');");
+
+ Assert.True(delivered, "Handler did not receive the event.");
+ var order = Assert.Single(recorder.Messages.OfType());
+ Assert.Equal("ORD-1", order.OrderId);
+ Assert.False(canCreateSchema);
+ Assert.False(canReadHistory);
}
finally
{
@@ -164,54 +188,39 @@ await ExecuteAsync(connection,
}
[Fact]
- public async Task StartAsync_Should_FailThroughConsumerRegistration_When_MigrationsDisabledOnEmptyDatabase()
+ public async Task StartAsync_Should_Fail_When_AutoMigrateDisabledAndDatabaseIsEmpty()
{
// arrange
await using var database = await fixture.CreateDatabaseAsync();
+ await using var connection = CreateSetupConnection(database.ConnectionString);
+ await connection.OpenAsync(CancellationToken);
var services = new ServiceCollection();
- services.AddMessageBus().AddPostgres(t => t.ConnectionString(database.ConnectionString).AutoMigrate(false));
- await using var provider = services.BuildServiceProvider();
+ services.AddMessageBus().AddPostgres(transport =>
+ transport.ConnectionString(database.ConnectionString).AutoMigrate(false));
+
+ var provider = services.BuildServiceProvider();
var runtime = (MessagingRuntime)provider.GetRequiredService();
await using var transport = runtime.Transports.OfType().Single();
+ await using var bus = new TestBus(provider, runtime);
// act
var exception = await Assert.ThrowsAsync(() => runtime.StartAsync(CancellationToken).AsTask());
// assert
+ var tableCount = await ExecuteScalarAsync(connection,
+ "SELECT count(*) FROM pg_tables WHERE schemaname = 'public';");
+
Assert.Equal(PostgresErrorCodes.UndefinedTable, exception.SqlState);
Assert.Equal("relation \"public.mocha_consumers\" does not exist", exception.MessageText);
- await using var connection = CreateSetupConnection(database.ConnectionString);
- await connection.OpenAsync(CancellationToken);
- Assert.Equal(0L, await ScalarAsync(connection, "SELECT count(*) FROM pg_tables WHERE schemaname = 'public';"));
+ Assert.Equal(0, tableCount);
}
- internal static async Task PublishAndReceiveAsync(string connectionString)
+ private static NpgsqlConnection CreateSetupConnection(string connectionString)
{
- var recorder = new MessageRecorder();
- var services = new ServiceCollection();
- services.AddSingleton(recorder).AddMessageBus().AddEventHandler()
- .AddPostgres(t => t.ConnectionString(connectionString).AutoMigrate(false).AutoProvision(true));
- await using var provider = services.BuildServiceProvider();
- var runtime = (MessagingRuntime)provider.GetRequiredService();
- await using var transport = runtime.Transports.OfType().Single();
- await runtime.StartAsync(CancellationToken);
- try
- {
- using var scope = provider.CreateScope();
- var bus = scope.ServiceProvider.GetRequiredService();
- await bus.PublishAsync(new OrderCreated { OrderId = "prepared-database" }, CancellationToken);
- Assert.True(await recorder.WaitAsync(TimeSpan.FromSeconds(30)), "Handler did not receive the event.");
- Assert.Equal("prepared-database", Assert.IsType(Assert.Single(recorder.Messages)).OrderId);
- }
- finally
- {
- await transport.StopAsync(runtime, CancellationToken.None);
- }
+ var settings = new NpgsqlConnectionStringBuilder(connectionString) { Pooling = false };
+ return new NpgsqlConnection(settings.ConnectionString);
}
- private static NpgsqlConnection CreateSetupConnection(string connectionString)
- => new(new NpgsqlConnectionStringBuilder(connectionString) { Pooling = false }.ConnectionString);
-
private static async Task ExecuteAsync(NpgsqlConnection connection, string sql)
{
await using var command = connection.CreateCommand();
@@ -219,10 +228,11 @@ private static async Task ExecuteAsync(NpgsqlConnection connection, string sql)
await command.ExecuteNonQueryAsync(CancellationToken);
}
- private static async Task ScalarAsync(NpgsqlConnection connection, string sql)
+ private static async Task ExecuteScalarAsync(NpgsqlConnection connection, string sql)
{
await using var command = connection.CreateCommand();
command.CommandText = sql;
- return await command.ExecuteScalarAsync(CancellationToken);
+ var result = await command.ExecuteScalarAsync(CancellationToken);
+ return (T)result!;
}
}
diff --git a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/PostgresMigrationScriptTests.cs b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/PostgresMigrationScriptTests.cs
index 7a8f60ae806..3015eba4387 100644
--- a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/PostgresMigrationScriptTests.cs
+++ b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/PostgresMigrationScriptTests.cs
@@ -12,9 +12,10 @@ public void GenerateMigrationsSql_Should_ReturnDeterministicSql_When_UsingDefaul
// act
var script = PostgresTransportSchema.GenerateMigrationsSql(options);
+ var repeatedScript = PostgresTransportSchema.GenerateMigrationsSql(options);
// assert
- Assert.Equal(script, PostgresTransportSchema.GenerateMigrationsSql(options));
+ Assert.Equal(script, repeatedScript);
Assert.Equal(script.ReplaceLineEndings("\n"), script);
script.MatchSnapshot();
}
diff --git a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/PostgresTransportSchemaTests.cs b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/PostgresTransportSchemaTests.cs
index 43223e2a903..516b004b8af 100644
--- a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/PostgresTransportSchemaTests.cs
+++ b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/PostgresTransportSchemaTests.cs
@@ -1,7 +1,6 @@
using System.Data;
using System.Transactions;
using CookieCrumble;
-using Mocha.Transport.Postgres.Tests.Behaviors;
using Mocha.Transport.Postgres.Tests.Helpers;
using Npgsql;
@@ -17,14 +16,16 @@ public async Task MigrateAsync_Should_RecordMigrations_When_DatabaseIsEmpty()
{
// arrange
await using var database = await fixture.CreateDatabaseAsync();
- await using var connection = await OpenAsync(database.ConnectionString);
+ await using var connection = await OpenConnectionAsync(database.ConnectionString);
// act
await PostgresTransportSchema.MigrateAsync(connection, new PostgresSchemaOptions(), CancellationToken);
// assert
+ var migrationHistory = await ReadMigrationHistoryAsync(connection);
+
Assert.Equal(ConnectionState.Open, connection.State);
- (await ReadHistoryAsync(connection)).MatchInlineSnapshot(
+ migrationHistory.MatchInlineSnapshot(
"""
[
"2026-03-06_AddConsumerManagement",
@@ -40,7 +41,7 @@ public async Task GenerateMigrationsSql_Should_MatchDirectMigration_When_ScriptI
// arrange
await using var direct = await fixture.CreateDatabaseAsync("ScriptParityDirect");
await using var scripted = await fixture.CreateDatabaseAsync("ScriptParityScripted");
- await using var connection = await OpenAsync(direct.ConnectionString);
+ await using var connection = await OpenConnectionAsync(direct.ConnectionString);
var options = new PostgresSchemaOptions();
await PostgresTransportSchema.MigrateAsync(connection, options, CancellationToken);
var script = PostgresTransportSchema.GenerateMigrationsSql(options);
@@ -48,26 +49,31 @@ public async Task GenerateMigrationsSql_Should_MatchDirectMigration_When_ScriptI
// act
await fixture.RunSqlScriptAsync(script, scripted.DatabaseName);
await fixture.RunSqlScriptAsync(script, scripted.DatabaseName);
- await using var scriptedConnection = await OpenAsync(scripted.ConnectionString);
+ await using var scriptedConnection = await OpenConnectionAsync(scripted.ConnectionString);
await PostgresTransportSchema.MigrateAsync(scriptedConnection, options, CancellationToken);
await fixture.RunSqlScriptAsync(script, direct.DatabaseName);
// assert
- Assert.Equal(await ReadSchemaAsync(connection), await ReadSchemaAsync(scriptedConnection));
- Assert.Equal(await ReadHistoryAsync(connection), await ReadHistoryAsync(scriptedConnection));
+ var directSchema = await ReadSchemaAsync(connection);
+ var scriptedSchema = await ReadSchemaAsync(scriptedConnection);
+ var directHistory = await ReadMigrationHistoryAsync(connection);
+ var scriptedHistory = await ReadMigrationHistoryAsync(scriptedConnection);
+
+ Assert.Equal(directSchema, scriptedSchema);
+ Assert.Equal(directHistory, scriptedHistory);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
- public async Task MigrateAsync_Should_UpgradeAndPreserveData_When_InitialMigrationAlreadyApplied(bool script)
+ public async Task MigrateAsync_Should_UpgradeAndPreserveData_When_InitialMigrationAlreadyApplied(bool useMigrationScript)
{
// arrange
await using var database = await fixture.CreateDatabaseAsync();
- await using var connection = await OpenAsync(database.ConnectionString);
+ await using var connection = await OpenConnectionAsync(database.ConnectionString);
var options = new PostgresSchemaOptions();
- await ExecuteAsync(connection, PostgresSchemaSql.InitialSchema(options));
- await ExecuteAsync(connection,
+ await ExecuteNonQueryAsync(connection, PostgresSchemaSql.InitialSchema(options));
+ await ExecuteNonQueryAsync(connection,
"""
CREATE TABLE public.mocha_migrations (
migration_id text PRIMARY KEY,
@@ -80,20 +86,16 @@ INSERT INTO public.mocha_message (body, queue_id)
""");
// act
- if (script)
- {
- await fixture.RunSqlScriptAsync(
- PostgresTransportSchema.GenerateMigrationsSql(options), database.DatabaseName);
- }
- else
- {
- await PostgresTransportSchema.MigrateAsync(connection, options, CancellationToken);
- }
- await using var freshConnection = await OpenAsync(database.ConnectionString);
- await ApplyAsync(freshConnection, options, !script);
+ await ApplyMigrationsAsync(connection, options, useMigrationScript);
// assert
- (await ReadHistoryAsync(connection)).MatchInlineSnapshot(
+ var migrationHistory = await ReadMigrationHistoryAsync(connection);
+ var queueName = await ExecuteScalarAsync(connection, "SELECT name FROM public.mocha_queue;");
+ var messageBody = await ExecuteScalarAsync(connection, "SELECT body FROM public.mocha_message;");
+
+ Assert.Equal("existing-queue", queueName);
+ Assert.Equal([1, 2], messageBody);
+ migrationHistory.MatchInlineSnapshot(
"""
[
"2026-03-06_AddConsumerManagement",
@@ -102,87 +104,117 @@ await fixture.RunSqlScriptAsync(
"future-migration"
]
""");
- Assert.Equal("existing-queue:0102", await ScalarAsync(connection,
- "SELECT q.name || ':' || encode(m.body, 'hex') FROM public.mocha_message m JOIN public.mocha_queue q ON q.id = m.queue_id"));
}
[Theory]
- [InlineData(0)]
- [InlineData(1)]
- [InlineData(2)]
- public async Task MigrateAsync_Should_SerializeCallers_When_MigrationsRunConcurrently(int mode)
+ [InlineData(false, false)]
+ [InlineData(true, true)]
+ [InlineData(false, true)]
+ public async Task MigrateAsync_Should_ApplyMigrationsOnce_When_CallersRunConcurrently(
+ bool firstUsesScript,
+ bool secondUsesScript)
{
// arrange
await using var database = await fixture.CreateDatabaseAsync();
+ await using var firstConnection = await OpenConnectionAsync(database.ConnectionString);
+ await using var secondConnection = await OpenConnectionAsync(database.ConnectionString);
var options = new PostgresSchemaOptions();
// act
- await Task.WhenAll(Enumerable.Range(0, 4).Select(async index =>
- {
- await using var connection = await OpenAsync(database.ConnectionString);
- await ApplyAsync(connection, options, mode == 1 || (mode == 2 && index % 2 == 0));
- }));
+ var firstMigration = ApplyMigrationsAsync(firstConnection, options, firstUsesScript);
+ var secondMigration = ApplyMigrationsAsync(secondConnection, options, secondUsesScript);
+ await Task.WhenAll(firstMigration, secondMigration);
// assert
- await using var verification = await OpenAsync(database.ConnectionString);
- Assert.Equal(3, (await ReadHistoryAsync(verification)).Length);
+ var migrationHistory = await ReadMigrationHistoryAsync(firstConnection);
+
+ Assert.Equal(3, migrationHistory.Length);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
- public async Task MigrateAsync_Should_RollBackAndAllowRetry_When_MigrationFails(bool script)
+ public async Task MigrateAsync_Should_RollBack_When_MigrationFails(bool useMigrationScript)
{
// arrange
await using var database = await fixture.CreateDatabaseAsync();
- await using var connection = await OpenAsync(database.ConnectionString);
- await ExecuteAsync(connection, "CREATE VIEW public.mocha_message AS SELECT 1 AS sentinel;");
+ await using var connection = await OpenConnectionAsync(database.ConnectionString);
+ var options = new PostgresSchemaOptions();
+ await ExecuteNonQueryAsync(connection, "CREATE VIEW public.mocha_message AS SELECT 1 AS sentinel;");
// act
- await Assert.ThrowsAsync(
- () => ApplyAsync(connection, new PostgresSchemaOptions(), script));
- if (script)
+ var exception = await Assert.ThrowsAsync(
+ () => ApplyMigrationsAsync(connection, options, useMigrationScript));
+
+ // assert
+ await using var verification = await OpenConnectionAsync(database.ConnectionString);
+ var tableCount = await ExecuteScalarAsync(verification,
+ "SELECT count(*) FROM pg_tables WHERE schemaname = 'public';");
+ var sequenceExists = await ExecuteScalarAsync(verification,
+ "SELECT to_regclass('public.mocha_topology_seq') IS NOT NULL;");
+
+ Assert.Equal(PostgresErrorCodes.WrongObjectType, exception.SqlState);
+ Assert.Equal(0, tableCount);
+ Assert.False(sequenceExists);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task MigrateAsync_Should_AllowRetry_When_PreviousMigrationFailed(bool useMigrationScript)
+ {
+ // arrange
+ await using var database = await fixture.CreateDatabaseAsync();
+ await using var connection = await OpenConnectionAsync(database.ConnectionString);
+ var options = new PostgresSchemaOptions();
+ await ExecuteNonQueryAsync(connection, "CREATE VIEW public.mocha_message AS SELECT 1 AS sentinel;");
+ await Assert.ThrowsAsync(() => ApplyMigrationsAsync(connection, options, useMigrationScript));
+
+ if (useMigrationScript)
{
- await ExecuteAsync(connection, "ROLLBACK;");
+ await ExecuteNonQueryAsync(connection, "ROLLBACK;");
}
+ await ExecuteNonQueryAsync(connection, "DROP VIEW public.mocha_message;");
+
+ // act
+ await ApplyMigrationsAsync(connection, options, useMigrationScript);
+
// assert
- Assert.Equal(0L, await ScalarAsync(connection,
- "SELECT count(*) FROM pg_tables WHERE schemaname = 'public';"));
- Assert.Equal(DBNull.Value, await ScalarAsync(connection,
- "SELECT to_regclass('public.mocha_topology_seq')::text;"));
- await ExecuteAsync(connection, "DROP VIEW public.mocha_message;");
- await ApplyAsync(connection, new PostgresSchemaOptions(), script);
- Assert.Equal(3, (await ReadHistoryAsync(connection)).Length);
+ var migrationHistory = await ReadMigrationHistoryAsync(connection);
+
+ Assert.Equal(3, migrationHistory.Length);
}
[Fact]
- public async Task MigrateAsync_Should_CancelAndAllowRetry_When_WaitingForExistingMigrationLock()
+ public async Task MigrateAsync_Should_AllowRetry_When_CancelledWhileWaitingForMigrationLock()
{
// arrange
await using var database = await fixture.CreateDatabaseAsync();
- await using var blocker = await OpenAsync(database.ConnectionString);
- await using var connection = await OpenAsync(database.ConnectionString);
+ await using var blocker = await OpenConnectionAsync(database.ConnectionString);
+ await using var connection = await OpenConnectionAsync(database.ConnectionString);
await using var transaction = await blocker.BeginTransactionAsync(CancellationToken);
- await ExecuteAsync(blocker, "SELECT pg_advisory_xact_lock(958913715);");
+ await ExecuteNonQueryAsync(blocker, "SELECT pg_advisory_xact_lock(958913715);");
using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken);
+ var options = new PostgresSchemaOptions();
// act
- var migration = PostgresTransportSchema.MigrateAsync(connection, new PostgresSchemaOptions(), cancellation.Token);
- using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
- while (!(bool)(await ScalarAsync(blocker,
- $"SELECT EXISTS (SELECT FROM pg_locks WHERE pid = {connection.ProcessID} AND locktype = 'advisory' AND NOT granted);"))!)
- {
- await Task.Delay(20, timeout.Token);
- }
+ var migration = PostgresTransportSchema.MigrateAsync(connection, options, cancellation.Token);
+ await WaitForMigrationLockAsync(blocker, connection.ProcessID);
await cancellation.CancelAsync();
+ var exception = await Record.ExceptionAsync(() => migration);
+ var historyExists = await ExecuteScalarAsync(connection,
+ "SELECT to_regclass('public.mocha_migrations') IS NOT NULL;");
- // assert
- await Assert.ThrowsAnyAsync(() => migration);
- Assert.Equal(DBNull.Value, await ScalarAsync(connection, "SELECT to_regclass('public.mocha_migrations')::text;"));
await transaction.RollbackAsync(CancellationToken);
- await PostgresTransportSchema.MigrateAsync(connection, new PostgresSchemaOptions(), CancellationToken);
- Assert.Equal(3, (await ReadHistoryAsync(connection)).Length);
+ await PostgresTransportSchema.MigrateAsync(connection, options, CancellationToken);
+
+ // assert
+ var migrationHistory = await ReadMigrationHistoryAsync(connection);
+
+ Assert.IsAssignableFrom(exception);
+ Assert.False(historyExists);
+ Assert.Equal(3, migrationHistory.Length);
}
[Theory]
@@ -193,16 +225,21 @@ public async Task MigrateAsync_Should_UseRuntimeNames_When_CustomNamesConfigured
{
// arrange
await using var database = await fixture.CreateDatabaseAsync();
- await using var connection = await OpenAsync(database.ConnectionString);
+ await using var connection = await OpenConnectionAsync(database.ConnectionString);
var options = new PostgresSchemaOptions { Schema = schema, TablePrefix = prefix };
// act
- await ApplyAsync(connection, options, true);
- await ApplyAsync(connection, options, false);
+ await ApplyMigrationsAsync(connection, options, useMigrationScript: true);
+ await ApplyMigrationsAsync(connection, options, useMigrationScript: false);
// assert
- Assert.Equal(3L, await ScalarAsync(connection, $"SELECT count(*) FROM {options.MigrationsTable};"));
- Assert.Equal(0L, await ScalarAsync(connection, $"SELECT count(*) FROM {options.QueueTable};"));
+ var migrationCount = await ExecuteScalarAsync(connection,
+ $"SELECT count(*) FROM {options.MigrationsTable};");
+ var queueCount = await ExecuteScalarAsync(connection,
+ $"SELECT count(*) FROM {options.QueueTable};");
+
+ Assert.Equal(3, migrationCount);
+ Assert.Equal(0, queueCount);
}
[Fact]
@@ -220,49 +257,74 @@ public async Task MigrateAsync_Should_RejectClosedConnection_When_CallerHasNotOp
Assert.Equal(ConnectionState.Closed, connection.State);
}
- [Theory]
- [InlineData(false)]
- [InlineData(true)]
- public async Task MigrateAsync_Should_RejectTransaction_When_CallerOwnsTransaction(bool ambient)
+ [Fact]
+ public async Task MigrateAsync_Should_RejectTransaction_When_ConnectionHasActiveTransaction()
{
// arrange
await using var database = await fixture.CreateDatabaseAsync();
- await using var connection = await OpenAsync(database.ConnectionString);
- using var scope = ambient ? new TransactionScope(TransactionScopeAsyncFlowOption.Enabled) : null;
- await using var transaction = ambient ? null : await connection.BeginTransactionAsync(CancellationToken);
+ await using var connection = await OpenConnectionAsync(database.ConnectionString);
+ await using var transaction = await connection.BeginTransactionAsync(CancellationToken);
+ var options = new PostgresSchemaOptions();
// act
var exception = await Assert.ThrowsAsync(
- () => PostgresTransportSchema.MigrateAsync(connection, new PostgresSchemaOptions(), CancellationToken));
+ () => PostgresTransportSchema.MigrateAsync(connection, options, CancellationToken));
// assert
+ var historyExists = await ExecuteScalarAsync(connection,
+ "SELECT to_regclass('public.mocha_migrations') IS NOT NULL;");
+
+ Assert.Equal("Transport migration requires a connection without an active or ambient transaction.", exception.Message);
+ Assert.False(historyExists);
+ Assert.Same(connection, transaction.Connection);
+ }
+
+ [Fact]
+ public async Task MigrateAsync_Should_RejectTransaction_When_AmbientTransactionExists()
+ {
+ // arrange
+ await using var database = await fixture.CreateDatabaseAsync();
+ await using var connection = await OpenConnectionAsync(database.ConnectionString);
+ using var transaction = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled);
+ var options = new PostgresSchemaOptions();
+
+ // act
+ var exception = await Assert.ThrowsAsync(
+ () => PostgresTransportSchema.MigrateAsync(connection, options, CancellationToken));
+
+ // assert
+ var historyExists = await ExecuteScalarAsync(connection,
+ "SELECT to_regclass('public.mocha_migrations') IS NOT NULL;");
+
Assert.Equal("Transport migration requires a connection without an active or ambient transaction.", exception.Message);
- Assert.Equal(DBNull.Value, await ScalarAsync(connection, "SELECT to_regclass('public.mocha_migrations')::text;"));
- Assert.Equal(1, await ScalarAsync(connection, "SELECT 1;"));
+ Assert.False(historyExists);
+ Assert.Equal(ConnectionState.Open, connection.State);
}
[Fact]
public async Task MigrateAsync_Should_PreserveHistory_When_DatabaseClonedFromTemplate()
{
// arrange
- await using var template = await fixture.CreateDatabaseAsync("MigrationTemplate");
- await using var clone = await fixture.CreateDatabaseAsync("MigrationClone");
- await using (var source = await OpenAsync(template.ConnectionString))
+ await using var template = await fixture.CreateDatabaseAsync();
+ string[] templateHistory;
+ await using (var connection = await OpenConnectionAsync(template.ConnectionString))
{
- await PostgresTransportSchema.MigrateAsync(source, new PostgresSchemaOptions(), CancellationToken);
+ await PostgresTransportSchema.MigrateAsync(connection, new PostgresSchemaOptions(), CancellationToken);
+ templateHistory = await ReadMigrationHistoryAsync(connection);
}
- await using var admin = await OpenAsync(fixture.ConnectionString);
// act
- await ExecuteAsync(admin, $"DROP DATABASE \"{clone.DatabaseName}\";");
- await ExecuteAsync(admin, $"CREATE DATABASE \"{clone.DatabaseName}\" TEMPLATE \"{template.DatabaseName}\";");
- await using var connection = await OpenAsync(clone.ConnectionString);
+ await using var clone = await CloneDatabaseAsync(template);
// assert
- Assert.Equal(3, (await ReadHistoryAsync(connection)).Length);
- Assert.Equal(0L, await ScalarAsync(connection, "SELECT count(*) FROM public.mocha_consumers;"));
- Assert.Equal(0L, await ScalarAsync(connection, "SELECT count(*) FROM public.mocha_queue;"));
- await AutoMigrateIntegrationTests.PublishAndReceiveAsync(clone.ConnectionString);
+ await using var verification = await OpenConnectionAsync(clone.ConnectionString);
+ var migrationHistory = await ReadMigrationHistoryAsync(verification);
+ var consumerCount = await ExecuteScalarAsync(verification, "SELECT count(*) FROM public.mocha_consumers;");
+ var queueCount = await ExecuteScalarAsync(verification, "SELECT count(*) FROM public.mocha_queue;");
+
+ Assert.Equal(0, consumerCount);
+ Assert.Equal(0, queueCount);
+ Assert.Equal(templateHistory, migrationHistory);
}
[Fact]
@@ -270,7 +332,7 @@ public async Task MigrateAsync_Should_LeaveDatabaseUnchanged_When_CancelledBefor
{
// arrange
await using var database = await fixture.CreateDatabaseAsync();
- await using var connection = await OpenAsync(database.ConnectionString);
+ await using var connection = await OpenConnectionAsync(database.ConnectionString);
using var cancellation = new CancellationTokenSource();
await cancellation.CancelAsync();
@@ -279,35 +341,41 @@ await Assert.ThrowsAnyAsync(
() => PostgresTransportSchema.MigrateAsync(connection, new PostgresSchemaOptions(), cancellation.Token));
// assert
- Assert.Equal(0L, await ScalarAsync(connection, "SELECT count(*) FROM pg_tables WHERE schemaname = 'public';"));
+ var tableCount = await ExecuteScalarAsync(connection,
+ "SELECT count(*) FROM pg_tables WHERE schemaname = 'public';");
+
+ Assert.Equal(0, tableCount);
Assert.Equal(ConnectionState.Open, connection.State);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
- public async Task MigrateAsync_Should_RejectReplay_When_HistoryWasErased(bool script)
+ public async Task MigrateAsync_Should_RejectReplay_When_HistoryWasErased(bool useMigrationScript)
{
// arrange
await using var database = await fixture.CreateDatabaseAsync();
- await using var connection = await OpenAsync(database.ConnectionString);
+ await using var connection = await OpenConnectionAsync(database.ConnectionString);
var options = new PostgresSchemaOptions();
await PostgresTransportSchema.MigrateAsync(connection, options, CancellationToken);
- await ExecuteAsync(connection, "TRUNCATE public.mocha_migrations;");
+ await ExecuteNonQueryAsync(connection, "TRUNCATE public.mocha_migrations;");
// act
- var exception = await Assert.ThrowsAsync(() => ApplyAsync(connection, options, script));
- if (script)
+ var exception = await Assert.ThrowsAsync(() => ApplyMigrationsAsync(connection, options, useMigrationScript));
+ if (useMigrationScript)
{
- await ExecuteAsync(connection, "ROLLBACK;");
+ await ExecuteNonQueryAsync(connection, "ROLLBACK;");
}
// assert
+ var migrationCount = await ExecuteScalarAsync(connection,
+ "SELECT count(*) FROM public.mocha_migrations;");
+
Assert.Equal(PostgresErrorCodes.DuplicateTable, exception.SqlState);
- Assert.Equal(0L, await ScalarAsync(connection, "SELECT count(*) FROM public.mocha_migrations;"));
+ Assert.Equal(0, migrationCount);
}
- private static async Task OpenAsync(string connectionString)
+ private static async Task OpenConnectionAsync(string connectionString)
{
var settings = new NpgsqlConnectionStringBuilder(connectionString) { Pooling = false, Enlist = false };
var connection = new NpgsqlConnection(settings.ConnectionString);
@@ -315,31 +383,44 @@ private static async Task OpenAsync(string connectionString)
return connection;
}
- private static Task ApplyAsync(NpgsqlConnection connection, PostgresSchemaOptions options, bool script)
- => script
- ? ExecuteAsync(connection, PostgresTransportSchema.GenerateMigrationsSql(options))
- : PostgresTransportSchema.MigrateAsync(connection, options, CancellationToken);
+ private static Task ApplyMigrationsAsync(
+ NpgsqlConnection connection,
+ PostgresSchemaOptions options,
+ bool useMigrationScript)
+ {
+ if (useMigrationScript)
+ {
+ var sql = PostgresTransportSchema.GenerateMigrationsSql(options);
+ return ExecuteNonQueryAsync(connection, sql);
+ }
- private static async Task ExecuteAsync(NpgsqlConnection connection, string sql)
+ return PostgresTransportSchema.MigrateAsync(connection, options, CancellationToken);
+ }
+
+ private static async Task ExecuteNonQueryAsync(NpgsqlConnection connection, string sql)
{
await using var command = connection.CreateCommand();
command.CommandText = sql;
await command.ExecuteNonQueryAsync(CancellationToken);
}
- private static async Task ScalarAsync(NpgsqlConnection connection, string sql)
+ private static async Task ExecuteScalarAsync(NpgsqlConnection connection, string sql)
{
await using var command = connection.CreateCommand();
command.CommandText = sql;
- return await command.ExecuteScalarAsync(CancellationToken);
+ var result = await command.ExecuteScalarAsync(CancellationToken);
+ return (T)result!;
}
- private static async Task ReadHistoryAsync(NpgsqlConnection connection)
- => (string[])(await ScalarAsync(connection,
- "SELECT array_agg(migration_id ORDER BY migration_id) FROM public.mocha_migrations;"))!;
+ private static Task ReadMigrationHistoryAsync(NpgsqlConnection connection)
+ {
+ const string sql = "SELECT array_agg(migration_id ORDER BY migration_id) FROM public.mocha_migrations;";
+ return ExecuteScalarAsync(connection, sql);
+ }
- private static async Task ReadSchemaAsync(NpgsqlConnection connection)
- => (string[])(await ScalarAsync(connection,
+ private static Task ReadSchemaAsync(NpgsqlConnection connection)
+ {
+ const string sql =
"""
SELECT array_agg(description ORDER BY description)
FROM (
@@ -354,5 +435,46 @@ UNION ALL
UNION ALL
SELECT 'index: ' || indexdef FROM pg_indexes WHERE schemaname = 'public'
) state;
- """))!;
+ """;
+
+ return ExecuteScalarAsync(connection, sql);
+ }
+
+ private static async Task WaitForMigrationLockAsync(NpgsqlConnection connection, int processId)
+ {
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken);
+ timeout.CancelAfter(TimeSpan.FromSeconds(10));
+ await using var command = connection.CreateCommand();
+ command.CommandText =
+ """
+ SELECT EXISTS (
+ SELECT FROM pg_locks
+ WHERE pid = @process_id AND locktype = 'advisory' AND NOT granted);
+ """;
+ command.Parameters.AddWithValue("process_id", processId);
+
+ while (true)
+ {
+ var waitingForLock = await command.ExecuteScalarAsync(timeout.Token);
+ if (waitingForLock is true)
+ {
+ return;
+ }
+
+ await Task.Delay(20, timeout.Token);
+ }
+ }
+
+ private async Task CloneDatabaseAsync(DatabaseContext template)
+ {
+ var databaseName = $"mocha_clone_{Guid.NewGuid():N}";
+ await using var connection = await OpenConnectionAsync(fixture.ConnectionString);
+ await ExecuteNonQueryAsync(connection, $"CREATE DATABASE \"{databaseName}\" TEMPLATE \"{template.DatabaseName}\";");
+
+ var settings = new NpgsqlConnectionStringBuilder(template.ConnectionString)
+ {
+ Database = databaseName
+ };
+ return new DatabaseContext(fixture, databaseName, settings.ConnectionString);
+ }
}
diff --git a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Descriptors/PostgresDescriptorTests.cs b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Descriptors/PostgresDescriptorTests.cs
index 78e26c4925b..98cefde76e4 100644
--- a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Descriptors/PostgresDescriptorTests.cs
+++ b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Descriptors/PostgresDescriptorTests.cs
@@ -7,41 +7,48 @@ namespace Mocha.Transport.Postgres.Tests.Descriptors;
public class PostgresDescriptorTests
{
[Theory]
- [InlineData(null, true)]
- [InlineData(null, false)]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task AutoMigrate_Should_DefaultToTrue_When_OnlyAutoProvisionConfigured(bool autoProvision)
+ {
+ // arrange
+ PostgresTransportConfiguration configuration = null!;
+
+ // act
+ var runtime = PostgresBusFixture.CreateRuntime(transport =>
+ {
+ transport.AutoProvision(autoProvision);
+ configuration = ((IMessagingDescriptor)transport).Extend().Configuration;
+ });
+ await using var transport = runtime.Transports.OfType().Single();
+
+ // assert
+ Assert.True(configuration.AutoMigrate);
+ Assert.Equal(autoProvision, configuration.AutoProvision);
+ }
+
+ [Theory]
[InlineData(true, true)]
[InlineData(true, false)]
[InlineData(false, true)]
[InlineData(false, false)]
- public async Task AutoMigrate_Should_ConfigureTransportIndependently_When_AutoProvisionIsSet(
- bool? autoMigrate,
- bool autoProvision)
+ public async Task AutoMigrate_Should_SetValue_When_AutoProvisionIsConfigured(bool autoMigrate, bool autoProvision)
{
// arrange
- IPostgresMessagingTransportDescriptor? descriptor = null;
- IPostgresMessagingTransportDescriptor? result = null;
- PostgresTransportConfiguration? configuration = null;
+ PostgresTransportConfiguration configuration = null!;
// act
- var runtime = PostgresBusFixture.CreateRuntime(t =>
+ var runtime = PostgresBusFixture.CreateRuntime(transport =>
{
- descriptor = t;
- if (autoMigrate.HasValue)
- {
- result = t.AutoMigrate(autoMigrate.Value);
- }
- t.AutoProvision(autoProvision);
- configuration = ((IMessagingDescriptor)t).Extend().Configuration;
+ transport.AutoMigrate(autoMigrate);
+ transport.AutoProvision(autoProvision);
+ configuration = ((IMessagingDescriptor)transport).Extend().Configuration;
});
await using var transport = runtime.Transports.OfType().Single();
// assert
- Assert.Equal(autoMigrate ?? true, configuration!.AutoMigrate);
+ Assert.Equal(autoMigrate, configuration.AutoMigrate);
Assert.Equal(autoProvision, configuration.AutoProvision);
- if (autoMigrate.HasValue)
- {
- Assert.Same(descriptor, result);
- }
}
[Fact]
From 99981ba5ef4b9d079b22286fc2b4519b5e96a3ee Mon Sep 17 00:00:00 2001
From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com>
Date: Tue, 6 Oct 2026 13:10:19 +0000
Subject: [PATCH 5/8] Cleanup
---
.../Descriptors/PostgresDescriptorTests.cs | 45 ------
.../content/docs/mocha/transports/postgres.md | 151 ++----------------
2 files changed, 15 insertions(+), 181 deletions(-)
diff --git a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Descriptors/PostgresDescriptorTests.cs b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Descriptors/PostgresDescriptorTests.cs
index 98cefde76e4..15b8eb28f16 100644
--- a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Descriptors/PostgresDescriptorTests.cs
+++ b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Descriptors/PostgresDescriptorTests.cs
@@ -6,51 +6,6 @@ namespace Mocha.Transport.Postgres.Tests.Descriptors;
public class PostgresDescriptorTests
{
- [Theory]
- [InlineData(true)]
- [InlineData(false)]
- public async Task AutoMigrate_Should_DefaultToTrue_When_OnlyAutoProvisionConfigured(bool autoProvision)
- {
- // arrange
- PostgresTransportConfiguration configuration = null!;
-
- // act
- var runtime = PostgresBusFixture.CreateRuntime(transport =>
- {
- transport.AutoProvision(autoProvision);
- configuration = ((IMessagingDescriptor)transport).Extend().Configuration;
- });
- await using var transport = runtime.Transports.OfType().Single();
-
- // assert
- Assert.True(configuration.AutoMigrate);
- Assert.Equal(autoProvision, configuration.AutoProvision);
- }
-
- [Theory]
- [InlineData(true, true)]
- [InlineData(true, false)]
- [InlineData(false, true)]
- [InlineData(false, false)]
- public async Task AutoMigrate_Should_SetValue_When_AutoProvisionIsConfigured(bool autoMigrate, bool autoProvision)
- {
- // arrange
- PostgresTransportConfiguration configuration = null!;
-
- // act
- var runtime = PostgresBusFixture.CreateRuntime(transport =>
- {
- transport.AutoMigrate(autoMigrate);
- transport.AutoProvision(autoProvision);
- configuration = ((IMessagingDescriptor)transport).Extend().Configuration;
- });
- await using var transport = runtime.Transports.OfType().Single();
-
- // assert
- Assert.Equal(autoMigrate, configuration.AutoMigrate);
- Assert.Equal(autoProvision, configuration.AutoProvision);
- }
-
[Fact]
public void Transport_Should_UseCustomSchema_When_SchemaConfigured()
{
diff --git a/website/content/docs/mocha/transports/postgres.md b/website/content/docs/mocha/transports/postgres.md
index f03b3392985..2f5432e1e43 100644
--- a/website/content/docs/mocha/transports/postgres.md
+++ b/website/content/docs/mocha/transports/postgres.md
@@ -243,158 +243,37 @@ Changing `Schema` and `TablePrefix` shifts all table names accordingly. For exam
## Schema migration
-The transport runs migrations automatically at startup using its runtime connection string. `AutoMigrate` defaults to `true`. Migrations use a PostgreSQL advisory lock (`pg_advisory_xact_lock`) and execute in one transaction, including schema creation and migration history updates.
+The transport runs migrations automatically at startup by default. Migrations are protected by a PostgreSQL advisory lock (`pg_advisory_xact_lock`) to prevent concurrent migration attempts from multiple service instances starting simultaneously.
-Each migration is recorded in `mocha_migrations`. Subsequent calls skip recorded migrations. Preserve this table when resetting test data or cloning a template database; migration does not reconstruct erased history from existing tables.
+Each migration is tracked in the migrations table and is idempotent - running the same migration twice has no effect. The migration creates the schema if it does not exist, then applies each pending migration in order within a single transaction.
-The advisory lock ID is fixed. Independent Mocha transports in the same database share the migration lock, including transports with different table prefixes. Normal message operations do not acquire this lock.
+> [!WARNING]
+> The advisory lock ID is fixed. Independent Mocha transports in the same database share this lock, including transports with different table prefixes. The lock serializes migrations without blocking normal message operations.
-### Separate setup and runtime credentials
-
-Call `PostgresTransportSchema.MigrateAsync` from an init container, devcontainer setup command, or test fixture. Supply an open connection with DDL privileges to an existing database:
-
-```csharp
-using Mocha.Transport.Postgres;
-using Npgsql;
-
-var options = new PostgresSchemaOptions();
-await using var connection = new NpgsqlConnection(setupConnectionString);
-await connection.OpenAsync(cancellationToken);
-await PostgresTransportSchema.MigrateAsync(connection, options, cancellationToken);
-```
-
-The caller owns the connection, which remains open after migration. The connection must have no active or ambient transaction. Migration commits before returning, rolls back on failure or cancellation, and supports cancellation while waiting for the migration lock.
-
-Start the service with restricted runtime credentials after setup succeeds:
+To set up the database schema at a different point in time, disable automatic migration:
```csharp
builder.Services.AddMessageBus().AddPostgres(transport =>
{
- transport.ConnectionString(runtimeConnectionString);
+ transport.ConnectionString(connectionString);
transport.AutoMigrate(false);
- transport.AutoProvision(true);
});
```
-`AutoMigrate(false)` skips migration completely. It does not validate the schema, inspect migration history, or repair missing objects. Missing infrastructure can fail through normal runtime database operations. `AutoProvision` independently controls insertion of topic, queue, and subscription rows; it does not control schema migration.
-
-Configure database access, schema usage, DML permissions on the operational tables, and usage of `mocha_topology_seq` separately through your deployment infrastructure. The runtime account does not need access to `mocha_migrations` when automatic migration is disabled.
-
-Transport migration creates transport tables, indexes, constraints, the topology sequence, and migration history. It does not seed configured topology or register a consumer. When using `Mocha.EntityFrameworkCore.Postgres`, apply application EF migrations separately to create the mapped outbox and business tables.
-
-### Export a migration SQL artifact
-
-`GenerateMigrationsSql` synchronously returns SQL without connecting to PostgreSQL, constructing a host, or discovering application topology:
-
-```csharp
-var sql = PostgresTransportSchema.GenerateMigrationsSql(new PostgresSchemaOptions());
-await File.WriteAllTextAsync("mocha-migrations.sql", sql, cancellationToken);
-```
-
-The artifact includes the complete migration catalog, transaction boundaries, advisory locking, and migration-history guards. It can initialize an empty database, upgrade a database with recorded older migrations, or run again against an up-to-date database. Direct migration and exported SQL use the same migration definitions and lock.
-
-Apply the artifact with a SQL client configured to stop on errors:
-
-```shell
-psql --no-psqlrc --set=ON_ERROR_STOP=1 --file=mocha-migrations.sql
-```
-
-Supply privileged connection settings to `psql` through your deployment environment. Execute the script outside an existing transaction; it already includes `BEGIN` and `COMMIT`. It contains no credentials, topology seed data, or EF migrations.
-
-### Add application setup commands
-
-This `Program.cs` exposes `mocha migrate` and `mocha export --file ` before constructing the service host. It requires `Microsoft.Extensions.Hosting`, `Mocha`, and `Mocha.Transport.Postgres`. Add application handlers and EF registrations to the normal service branch.
+Call `PostgresTransportSchema.MigrateAsync` before the transport is started. Use an open connection with schema modification permissions and the same schema options as the transport:
```csharp
-using System.Text;
-using Microsoft.Extensions.Hosting;
-using Mocha;
-using Mocha.Transport.Postgres;
-using Npgsql;
-
+// Match the transport's schema and table prefix.
var options = new PostgresSchemaOptions();
-if (args is ["mocha", .. var command])
-{
- using var cancellation = new CancellationTokenSource();
- Console.CancelKeyPress += (_, e) =>
- {
- e.Cancel = true;
- cancellation.Cancel();
- };
- var ct = cancellation.Token;
-
- if (command is ["export", "--file", var file])
- {
- ct.ThrowIfCancellationRequested();
- var sql = PostgresTransportSchema.GenerateMigrationsSql(options);
- var destination = Path.GetFullPath(file);
- var temporary = destination + "." + Guid.NewGuid().ToString("N") + ".tmp";
- try
- {
- await File.WriteAllTextAsync(temporary, sql, new UTF8Encoding(false), ct);
- ct.ThrowIfCancellationRequested();
- File.Move(temporary, destination, overwrite: true);
- }
- finally
- {
- File.Delete(temporary);
- }
-
- return 0;
- }
-
- if (command is ["migrate"])
- {
- var setup = Environment.GetEnvironmentVariable("MOCHA_SETUP_CONNECTION");
- if (string.IsNullOrWhiteSpace(setup))
- {
- Console.Error.WriteLine("Set MOCHA_SETUP_CONNECTION.");
- return 2;
- }
-
- var settings = new NpgsqlConnectionStringBuilder(setup)
- {
- Pooling = false,
- Enlist = false
- };
- await using var connection = new NpgsqlConnection(settings.ConnectionString);
- await connection.OpenAsync(ct);
- await PostgresTransportSchema.MigrateAsync(connection, options, ct);
- return 0;
- }
-
- Console.Error.WriteLine("Usage: mocha migrate | mocha export --file ");
- return 2;
-}
-
-var runtime = Environment.GetEnvironmentVariable("MOCHA_RUNTIME_CONNECTION");
-if (string.IsNullOrWhiteSpace(runtime))
-{
- Console.Error.WriteLine("Set MOCHA_RUNTIME_CONNECTION.");
- return 2;
-}
-
-var builder = Host.CreateApplicationBuilder(args);
-builder.Services.AddMessageBus().AddPostgres(transport =>
-{
- transport.ConnectionString(runtime).AutoMigrate(false).AutoProvision(true);
- ((IMessagingDescriptor)transport)
- .Extend().Configuration.SchemaOptions = options;
-});
-using var host = builder.Build();
-await host.RunAsync();
-return 0;
+await using var connection = new NpgsqlConnection(connectionString);
+await connection.OpenAsync(cancellationToken);
+await PostgresTransportSchema.MigrateAsync(
+ connection,
+ options,
+ cancellationToken);
```
-Run `dotnet MyService.dll mocha migrate` with `MOCHA_SETUP_CONNECTION` in the setup container, then start the service with `MOCHA_RUNTIME_CONNECTION`. To export during the build, run `dotnet MyService.dll mocha export --file mocha-migrations.sql`. Export needs no connection string; its output directory must already exist. Failed writes do not replace an existing artifact, and command failures return a nonzero exit code.
-
-### Prepare Testcontainers and template databases
-
-For a fresh Testcontainers database, apply any application EF migrations and call `MigrateAsync` before starting the bus with `AutoMigrate(false)`. Keep `AutoProvision(true)` to create the topology rows needed by test handlers.
-
-For template-based fixtures, apply both sets of migrations once to the template without starting a bus or outbox worker. Use nonpooled setup connections and dispose them before `CREATE DATABASE ... TEMPLATE ...`. Preserve EF and Mocha migration history in each clone. Start the test bus against the clone with `AutoMigrate(false)` and `AutoProvision(true)`; each clone gets its own runtime topology and consumer rows.
-
# Configure queues
Use `transport.Queue("name")` when you need to customize a PostgreSQL queue. The queue builder is the primary API for queue names, auto-provisioning, auto-delete, handler assignment, source bindings, batch size, concurrency, fault queues, and skipped queues.
@@ -481,7 +360,7 @@ If the queue already declares `AutoDelete(false)` explicitly, `Temporary()` fail
# Control auto-provisioning
-By default, the transport auto-provisions topology resources (topics, queues, subscriptions) as rows at startup. Disable auto-provisioning when these rows are managed externally. To manage table creation and migration externally, use `AutoMigrate(false)` as described in [Schema migration](#schema-migration).
+By default, the transport auto-provisions topology resources (topics, queues, subscriptions) as rows at startup. Disable auto-provisioning when these rows are managed externally. Schema migration is controlled separately by `AutoMigrate`, as described in [Schema migration](#schema-migration).
## Disable globally
From 0f7a4898788a36b55a263c2313f04b1656462ab2 Mon Sep 17 00:00:00 2001
From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com>
Date: Tue, 6 Oct 2026 13:37:42 +0000
Subject: [PATCH 6/8] Cleanup
---
....EntityFrameworkCore.Postgres.Tests.csproj | 1 -
.../PostgresMigrationOutboxTests.cs | 105 ----
.../Behaviors/AutoMigrateIntegrationTests.cs | 238 ---------
.../PostgresMigrationScriptTests.cs | 22 -
.../PostgresTransportSchemaTests.cs | 494 ++++++------------
...rnDeterministicSql_When_UsingDefaults.snap | 118 -----
...ons_When_OnlyInitialMigrationIsRecorded.md | 89 ++++
...ld_CreateResources_When_DatabaseIsEmpty.md | 89 ++++
.../Helpers/PostgresFixture.cs | 3 -
9 files changed, 337 insertions(+), 822 deletions(-)
delete mode 100644 src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/PostgresMigrationOutboxTests.cs
delete mode 100644 src/Mocha/test/Mocha.Transport.Postgres.Tests/Behaviors/AutoMigrateIntegrationTests.cs
delete mode 100644 src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/PostgresMigrationScriptTests.cs
delete mode 100644 src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/__snapshots__/PostgresMigrationScriptTests.GenerateMigrationsSql_Should_ReturnDeterministicSql_When_UsingDefaults.snap
create mode 100644 src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/__snapshots__/PostgresTransportSchemaTests.MigrateAsync_Should_ApplyPendingMigrations_When_OnlyInitialMigrationIsRecorded.md
create mode 100644 src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/__snapshots__/PostgresTransportSchemaTests.MigrateAsync_Should_CreateResources_When_DatabaseIsEmpty.md
diff --git a/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/Mocha.EntityFrameworkCore.Postgres.Tests.csproj b/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/Mocha.EntityFrameworkCore.Postgres.Tests.csproj
index bace2e87eb2..4d64f5b1923 100644
--- a/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/Mocha.EntityFrameworkCore.Postgres.Tests.csproj
+++ b/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/Mocha.EntityFrameworkCore.Postgres.Tests.csproj
@@ -14,7 +14,6 @@
-
diff --git a/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/PostgresMigrationOutboxTests.cs b/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/PostgresMigrationOutboxTests.cs
deleted file mode 100644
index 455b8153336..00000000000
--- a/src/Mocha/test/Mocha.EntityFrameworkCore.Postgres.Tests/PostgresMigrationOutboxTests.cs
+++ /dev/null
@@ -1,105 +0,0 @@
-using System.Text.Json;
-using Microsoft.EntityFrameworkCore;
-using Microsoft.Extensions.DependencyInjection;
-using Mocha.EntityFrameworkCore.Postgres.Tests.Helpers;
-using Mocha.Outbox;
-using Mocha.Transport.Postgres;
-using Npgsql;
-
-namespace Mocha.EntityFrameworkCore.Postgres.Tests;
-
-public sealed class PostgresMigrationOutboxTests(PostgresFixture fixture) : IClassFixture
-{
- [Theory]
- [InlineData(false)]
- [InlineData(true)]
- public async Task PublishAsync_Should_PersistOutboxMessage_When_DatabasePreparedBeforeRuntime(bool cloneFromTemplate)
- {
- // arrange
- var cancellationToken = TestContext.Current.CancellationToken;
- var connectionString = await CreateMigratedDatabaseAsync(cancellationToken);
- if (cloneFromTemplate)
- {
- connectionString = await CloneDatabaseAsync(connectionString, cancellationToken);
- }
-
- var services = new ServiceCollection();
- services.AddDbContext(options => options.UseTestNpgsql(connectionString));
- services.AddSingleton();
- services.AddMessageBus()
- .AddEntityFramework(ef => ef.UsePostgresOutbox())
- .AddPostgres(transport =>
- {
- transport.ConnectionString(connectionString);
- transport.AutoMigrate(false);
- transport.DeclareTopic("prepared-events");
- transport.DispatchEndpoint("prepared-events").ToTopic("prepared-events").Publish();
- });
-
- await using var provider = services.BuildServiceProvider();
- var runtime = (MessagingRuntime)provider.GetRequiredService();
- await using var transport = runtime.Transports.OfType().Single();
- var expectedEvent = new PreparedEvent("persisted-before-dispatch");
- await runtime.StartAsync(cancellationToken);
-
- try
- {
- // act
- await using (var scope = provider.CreateAsyncScope())
- {
- var messageBus = scope.ServiceProvider.GetRequiredService();
- await messageBus.PublishAsync(expectedEvent, cancellationToken);
- }
-
- // assert
- await using var verification = provider.CreateAsyncScope();
- var context = verification.ServiceProvider.GetRequiredService();
- var message = await context.Set().AsNoTracking().SingleAsync(cancellationToken);
- var body = message.Envelope.RootElement.GetProperty("body");
- var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
- var persistedEvent = body.Deserialize(jsonOptions);
-
- Assert.Equal(expectedEvent, persistedEvent);
- Assert.Equal(0, message.TimesSent);
- }
- finally
- {
- await transport.StopAsync(runtime, CancellationToken.None);
- }
- }
-
- private async Task CreateMigratedDatabaseAsync(CancellationToken cancellationToken)
- {
- var connectionString = await fixture.CreateDatabaseAsync();
- var settings = new NpgsqlConnectionStringBuilder(connectionString) { Pooling = false };
- var options = new DbContextOptionsBuilder()
- .UseTestNpgsql(settings.ConnectionString)
- .Options;
- await using var context = new TestDbContext(options);
- await context.Database.EnsureCreatedAsync(cancellationToken);
- await context.Database.OpenConnectionAsync(cancellationToken);
- var connection = (NpgsqlConnection)context.Database.GetDbConnection();
- await PostgresTransportSchema.MigrateAsync(connection, new PostgresSchemaOptions(), cancellationToken);
-
- return settings.ConnectionString;
- }
-
- private static async Task CloneDatabaseAsync(
- string templateConnectionString,
- CancellationToken cancellationToken)
- {
- var template = new NpgsqlConnectionStringBuilder(templateConnectionString);
- var cloneName = $"mocha_clone_{Guid.NewGuid():N}";
- var adminSettings = new NpgsqlConnectionStringBuilder(templateConnectionString) { Database = "postgres" };
- await using var connection = new NpgsqlConnection(adminSettings.ConnectionString);
- await connection.OpenAsync(cancellationToken);
- await using var command = connection.CreateCommand();
- command.CommandText = $"CREATE DATABASE \"{cloneName}\" TEMPLATE \"{template.Database}\";";
- await command.ExecuteNonQueryAsync(cancellationToken);
-
- var cloneSettings = new NpgsqlConnectionStringBuilder(templateConnectionString) { Database = cloneName };
- return cloneSettings.ConnectionString;
- }
-
- public sealed record PreparedEvent(string Payload);
-}
diff --git a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Behaviors/AutoMigrateIntegrationTests.cs b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Behaviors/AutoMigrateIntegrationTests.cs
deleted file mode 100644
index c83c2a4c7d9..00000000000
--- a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Behaviors/AutoMigrateIntegrationTests.cs
+++ /dev/null
@@ -1,238 +0,0 @@
-using Microsoft.Extensions.DependencyInjection;
-using Mocha.Transport.Postgres.Tests.Helpers;
-using Npgsql;
-
-namespace Mocha.Transport.Postgres.Tests.Behaviors;
-
-[Collection("Postgres")]
-public class AutoMigrateIntegrationTests(PostgresFixture fixture)
-{
- private static readonly TimeSpan s_timeout = TimeSpan.FromSeconds(30);
- private static CancellationToken CancellationToken => TestContext.Current.CancellationToken;
-
- [Theory]
- [InlineData(null)]
- [InlineData(true)]
- public async Task StartAsync_Should_CreateSchema_When_AutoMigrateEnabled(bool? autoMigrate)
- {
- // arrange
- await using var database = await fixture.CreateDatabaseAsync();
- await using var connection = CreateSetupConnection(database.ConnectionString);
- await connection.OpenAsync(CancellationToken);
- var services = new ServiceCollection();
- services.AddMessageBus().AddPostgres(transport =>
- {
- transport.ConnectionString(database.ConnectionString);
- transport.AutoProvision(false);
-
- if (autoMigrate.HasValue)
- {
- transport.AutoMigrate(autoMigrate.Value);
- }
- });
-
- var provider = services.BuildServiceProvider();
- var runtime = (MessagingRuntime)provider.GetRequiredService();
- await using var transport = runtime.Transports.OfType().Single();
- await using var bus = new TestBus(provider, runtime);
-
- // act
- await runtime.StartAsync(CancellationToken);
-
- // assert
- var migrationCount = await ExecuteScalarAsync(connection,
- "SELECT count(*) FROM public.mocha_migrations;");
- var consumerCount = await ExecuteScalarAsync(connection,
- "SELECT count(*) FROM public.mocha_consumers;");
-
- Assert.Equal(3, migrationCount);
- Assert.Equal(1, consumerCount);
- }
-
- [Fact]
- public async Task StartAsync_Should_UpgradeSchema_When_InitialMigrationAlreadyApplied()
- {
- // arrange
- await using var database = await fixture.CreateDatabaseAsync();
- await using var connection = CreateSetupConnection(database.ConnectionString);
- await connection.OpenAsync(CancellationToken);
- await ExecuteAsync(connection, PostgresSchemaSql.InitialSchema(new PostgresSchemaOptions()));
- await ExecuteAsync(connection,
- """
- CREATE TABLE public.mocha_migrations (
- migration_id text PRIMARY KEY,
- applied_on timestamptz NOT NULL DEFAULT now());
- INSERT INTO public.mocha_migrations (migration_id) VALUES ('2026-03-06_InitialSchema');
- """);
-
- var services = new ServiceCollection();
- services.AddMessageBus().AddPostgres(transport =>
- {
- transport.ConnectionString(database.ConnectionString);
- transport.AutoProvision(false);
- });
-
- var provider = services.BuildServiceProvider();
- var runtime = (MessagingRuntime)provider.GetRequiredService();
- await using var transport = runtime.Transports.OfType().Single();
- await using var bus = new TestBus(provider, runtime);
-
- // act
- await runtime.StartAsync(CancellationToken);
-
- // assert
- var migrationCount = await ExecuteScalarAsync(connection,
- "SELECT count(*) FROM public.mocha_migrations;");
- var consumerCount = await ExecuteScalarAsync(connection,
- "SELECT count(*) FROM public.mocha_consumers;");
-
- Assert.Equal(3, migrationCount);
- Assert.Equal(1, consumerCount);
- }
-
- [Fact]
- public async Task PublishAsync_Should_DeliverWithoutMigrationHistory_When_AutoMigrateDisabled()
- {
- // arrange
- var recorder = new MessageRecorder();
- await using var database = await fixture.CreateDatabaseAsync();
- await using var connection = CreateSetupConnection(database.ConnectionString);
- await connection.OpenAsync(CancellationToken);
- await PostgresTransportSchema.MigrateAsync(connection, new PostgresSchemaOptions(), CancellationToken);
- await ExecuteAsync(connection, "DROP TABLE public.mocha_migrations;");
-
- await using var bus = await new ServiceCollection()
- .AddSingleton(recorder)
- .AddMessageBus()
- .AddEventHandler()
- .AddPostgres(transport => transport.ConnectionString(database.ConnectionString).AutoMigrate(false))
- .BuildTestBusAsync();
-
- using var scope = bus.Provider.CreateScope();
- var messageBus = scope.ServiceProvider.GetRequiredService();
-
- // act
- await messageBus.PublishAsync(new OrderCreated { OrderId = "ORD-1" }, CancellationToken);
- var delivered = await recorder.WaitAsync(s_timeout);
-
- // assert
- var historyExists = await ExecuteScalarAsync(connection,
- "SELECT to_regclass('public.mocha_migrations') IS NOT NULL;");
-
- Assert.True(delivered, "Handler did not receive the event.");
- var message = Assert.Single(recorder.Messages);
- var order = Assert.IsType(message);
- Assert.Equal("ORD-1", order.OrderId);
- Assert.False(historyExists);
- }
-
- [Fact]
- public async Task PublishAsync_Should_Deliver_When_RuntimeHasOnlyDmlPermissions()
- {
- // arrange
- var recorder = new MessageRecorder();
- await using var database = await fixture.CreateDatabaseAsync();
- await using var connection = CreateSetupConnection(database.ConnectionString);
- await connection.OpenAsync(CancellationToken);
- await PostgresTransportSchema.MigrateAsync(connection, new PostgresSchemaOptions(), CancellationToken);
- var role = $"mocha_runtime_{Guid.NewGuid():N}";
- await ExecuteAsync(connection, $"CREATE ROLE {role} LOGIN PASSWORD 'runtime-test';");
-
- try
- {
- await ExecuteAsync(connection,
- $"""
- GRANT CONNECT ON DATABASE "{database.DatabaseName}" TO {role};
- GRANT USAGE ON SCHEMA public TO {role};
- GRANT SELECT, INSERT, UPDATE, DELETE ON
- public.mocha_topic, public.mocha_queue, public.mocha_queue_subscription,
- public.mocha_message, public.mocha_consumers TO {role};
- GRANT USAGE ON SEQUENCE public.mocha_topology_seq TO {role};
- """);
- var settings = new NpgsqlConnectionStringBuilder(database.ConnectionString)
- {
- Username = role,
- Password = "runtime-test",
- Pooling = false
- };
- await using var bus = await new ServiceCollection()
- .AddSingleton(recorder)
- .AddMessageBus()
- .AddEventHandler()
- .AddPostgres(transport => transport.ConnectionString(settings.ConnectionString).AutoMigrate(false))
- .BuildTestBusAsync();
-
- using var scope = bus.Provider.CreateScope();
- var messageBus = scope.ServiceProvider.GetRequiredService();
-
- // act
- await messageBus.PublishAsync(new OrderCreated { OrderId = "ORD-1" }, CancellationToken);
- var delivered = await recorder.WaitAsync(s_timeout);
-
- // assert
- var canCreateSchema = await ExecuteScalarAsync(connection,
- $"SELECT has_schema_privilege('{role}', 'public', 'CREATE');");
- var canReadHistory = await ExecuteScalarAsync(connection,
- $"SELECT has_table_privilege('{role}', 'public.mocha_migrations', 'SELECT');");
-
- Assert.True(delivered, "Handler did not receive the event.");
- var order = Assert.Single(recorder.Messages.OfType());
- Assert.Equal("ORD-1", order.OrderId);
- Assert.False(canCreateSchema);
- Assert.False(canReadHistory);
- }
- finally
- {
- await ExecuteAsync(connection, $"DROP OWNED BY {role}; DROP ROLE {role};");
- }
- }
-
- [Fact]
- public async Task StartAsync_Should_Fail_When_AutoMigrateDisabledAndDatabaseIsEmpty()
- {
- // arrange
- await using var database = await fixture.CreateDatabaseAsync();
- await using var connection = CreateSetupConnection(database.ConnectionString);
- await connection.OpenAsync(CancellationToken);
- var services = new ServiceCollection();
- services.AddMessageBus().AddPostgres(transport =>
- transport.ConnectionString(database.ConnectionString).AutoMigrate(false));
-
- var provider = services.BuildServiceProvider();
- var runtime = (MessagingRuntime)provider.GetRequiredService();
- await using var transport = runtime.Transports.OfType().Single();
- await using var bus = new TestBus(provider, runtime);
-
- // act
- var exception = await Assert.ThrowsAsync(() => runtime.StartAsync(CancellationToken).AsTask());
-
- // assert
- var tableCount = await ExecuteScalarAsync(connection,
- "SELECT count(*) FROM pg_tables WHERE schemaname = 'public';");
-
- Assert.Equal(PostgresErrorCodes.UndefinedTable, exception.SqlState);
- Assert.Equal("relation \"public.mocha_consumers\" does not exist", exception.MessageText);
- Assert.Equal(0, tableCount);
- }
-
- private static NpgsqlConnection CreateSetupConnection(string connectionString)
- {
- var settings = new NpgsqlConnectionStringBuilder(connectionString) { Pooling = false };
- return new NpgsqlConnection(settings.ConnectionString);
- }
-
- private static async Task ExecuteAsync(NpgsqlConnection connection, string sql)
- {
- await using var command = connection.CreateCommand();
- command.CommandText = sql;
- await command.ExecuteNonQueryAsync(CancellationToken);
- }
-
- private static async Task ExecuteScalarAsync(NpgsqlConnection connection, string sql)
- {
- await using var command = connection.CreateCommand();
- command.CommandText = sql;
- var result = await command.ExecuteScalarAsync(CancellationToken);
- return (T)result!;
- }
-}
diff --git a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/PostgresMigrationScriptTests.cs b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/PostgresMigrationScriptTests.cs
deleted file mode 100644
index 3015eba4387..00000000000
--- a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/PostgresMigrationScriptTests.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-using CookieCrumble;
-
-namespace Mocha.Transport.Postgres.Tests.Connection;
-
-public class PostgresMigrationScriptTests
-{
- [Fact]
- public void GenerateMigrationsSql_Should_ReturnDeterministicSql_When_UsingDefaults()
- {
- // arrange
- var options = new PostgresSchemaOptions();
-
- // act
- var script = PostgresTransportSchema.GenerateMigrationsSql(options);
- var repeatedScript = PostgresTransportSchema.GenerateMigrationsSql(options);
-
- // assert
- Assert.Equal(script, repeatedScript);
- Assert.Equal(script.ReplaceLineEndings("\n"), script);
- script.MatchSnapshot();
- }
-}
diff --git a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/PostgresTransportSchemaTests.cs b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/PostgresTransportSchemaTests.cs
index 516b004b8af..00619819ad4 100644
--- a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/PostgresTransportSchemaTests.cs
+++ b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/PostgresTransportSchemaTests.cs
@@ -1,6 +1,5 @@
-using System.Data;
-using System.Transactions;
using CookieCrumble;
+using Microsoft.Extensions.DependencyInjection;
using Mocha.Transport.Postgres.Tests.Helpers;
using Npgsql;
@@ -9,370 +8,239 @@ namespace Mocha.Transport.Postgres.Tests.Connection;
[Collection("Postgres")]
public class PostgresTransportSchemaTests(PostgresFixture fixture)
{
+ private readonly PostgresSchemaOptions _schemaOptions = new() { Schema = "messaging" };
private static CancellationToken CancellationToken => TestContext.Current.CancellationToken;
[Fact]
- public async Task MigrateAsync_Should_RecordMigrations_When_DatabaseIsEmpty()
+ public async Task StartAsync_Should_CreateSchema_When_AutoMigrateEnabledAndSchemaPermissionsGranted()
{
// arrange
await using var database = await fixture.CreateDatabaseAsync();
await using var connection = await OpenConnectionAsync(database.ConnectionString);
+ var services = new ServiceCollection();
+ services.AddMessageBus()
+ .AddPostgres(transport =>
+ {
+ transport.ConnectionString(database.ConnectionString);
+ transport.ExtendWith(extension => extension.Configuration.SchemaOptions = _schemaOptions);
+ });
+ var provider = services.BuildServiceProvider();
+ var runtime = (MessagingRuntime)provider.GetRequiredService();
+ await using var transport = runtime.Transports.OfType().Single();
+ await using var bus = new TestBus(provider, runtime);
// act
- await PostgresTransportSchema.MigrateAsync(connection, new PostgresSchemaOptions(), CancellationToken);
+ await runtime.StartAsync(CancellationToken);
// assert
var migrationHistory = await ReadMigrationHistoryAsync(connection);
- Assert.Equal(ConnectionState.Open, connection.State);
- migrationHistory.MatchInlineSnapshot(
- """
- [
- "2026-03-06_AddConsumerManagement",
- "2026-03-06_AddTransportIndex",
- "2026-03-06_InitialSchema"
- ]
- """);
+ Assert.Equal(
+ ["2026-03-06_AddConsumerManagement", "2026-03-06_AddTransportIndex", "2026-03-06_InitialSchema"],
+ migrationHistory);
}
[Fact]
- public async Task GenerateMigrationsSql_Should_MatchDirectMigration_When_ScriptIsAppliedRepeatedly()
+ public async Task StartAsync_Should_Fail_When_AutoMigrateEnabledWithoutSchemaPermissions()
{
// arrange
- await using var direct = await fixture.CreateDatabaseAsync("ScriptParityDirect");
- await using var scripted = await fixture.CreateDatabaseAsync("ScriptParityScripted");
- await using var connection = await OpenConnectionAsync(direct.ConnectionString);
- var options = new PostgresSchemaOptions();
- await PostgresTransportSchema.MigrateAsync(connection, options, CancellationToken);
- var script = PostgresTransportSchema.GenerateMigrationsSql(options);
-
- // act
- await fixture.RunSqlScriptAsync(script, scripted.DatabaseName);
- await fixture.RunSqlScriptAsync(script, scripted.DatabaseName);
- await using var scriptedConnection = await OpenConnectionAsync(scripted.ConnectionString);
- await PostgresTransportSchema.MigrateAsync(scriptedConnection, options, CancellationToken);
- await fixture.RunSqlScriptAsync(script, direct.DatabaseName);
-
- // assert
- var directSchema = await ReadSchemaAsync(connection);
- var scriptedSchema = await ReadSchemaAsync(scriptedConnection);
- var directHistory = await ReadMigrationHistoryAsync(connection);
- var scriptedHistory = await ReadMigrationHistoryAsync(scriptedConnection);
+ await using var database = await fixture.CreateDatabaseAsync();
+ await using var connection = await OpenConnectionAsync(database.ConnectionString);
+ var role = $"mocha_runtime_{Guid.NewGuid():N}";
+ await ExecuteNonQueryAsync(connection, $"CREATE ROLE {role} LOGIN PASSWORD 'runtime-test';");
- Assert.Equal(directSchema, scriptedSchema);
- Assert.Equal(directHistory, scriptedHistory);
+ try
+ {
+ await ExecuteNonQueryAsync(connection,
+ $"""
+ GRANT CONNECT ON DATABASE "{database.DatabaseName}" TO {role};
+ """);
+ var settings = new NpgsqlConnectionStringBuilder(database.ConnectionString)
+ {
+ Username = role,
+ Password = "runtime-test",
+ Pooling = false
+ };
+ var services = new ServiceCollection();
+ services.AddMessageBus()
+ .AddPostgres(transport =>
+ {
+ transport.ConnectionString(settings.ConnectionString);
+ transport.ExtendWith(extension => extension.Configuration.SchemaOptions = _schemaOptions);
+ });
+ var provider = services.BuildServiceProvider();
+ var runtime = (MessagingRuntime)provider.GetRequiredService();
+ await using var transport = runtime.Transports.OfType().Single();
+ await using var bus = new TestBus(provider, runtime);
+
+ // act
+ var exception = await Assert.ThrowsAsync(
+ () => runtime.StartAsync(CancellationToken).AsTask());
+
+ // assert
+ Assert.Equal(PostgresErrorCodes.InsufficientPrivilege, exception.SqlState);
+ }
+ finally
+ {
+ await ExecuteNonQueryAsync(connection, $"DROP OWNED BY {role}; DROP ROLE {role};");
+ }
}
[Theory]
[InlineData(false)]
[InlineData(true)]
- public async Task MigrateAsync_Should_UpgradeAndPreserveData_When_InitialMigrationAlreadyApplied(bool useMigrationScript)
+ public async Task MigrateAsync_Should_CreateResources_When_DatabaseIsEmpty(bool useMigrationScript)
{
// arrange
await using var database = await fixture.CreateDatabaseAsync();
await using var connection = await OpenConnectionAsync(database.ConnectionString);
- var options = new PostgresSchemaOptions();
- await ExecuteNonQueryAsync(connection, PostgresSchemaSql.InitialSchema(options));
- await ExecuteNonQueryAsync(connection,
- """
- CREATE TABLE public.mocha_migrations (
- migration_id text PRIMARY KEY,
- applied_on timestamptz NOT NULL DEFAULT now());
- INSERT INTO public.mocha_migrations (migration_id)
- VALUES ('2026-03-06_InitialSchema'), ('future-migration');
- INSERT INTO public.mocha_queue (name) VALUES ('existing-queue');
- INSERT INTO public.mocha_message (body, queue_id)
- SELECT '\x0102'::bytea, id FROM public.mocha_queue;
- """);
// act
- await ApplyMigrationsAsync(connection, options, useMigrationScript);
+ await ApplyMigrationsAsync(connection, useMigrationScript);
// assert
var migrationHistory = await ReadMigrationHistoryAsync(connection);
- var queueName = await ExecuteScalarAsync(connection, "SELECT name FROM public.mocha_queue;");
- var messageBody = await ExecuteScalarAsync(connection, "SELECT body FROM public.mocha_message;");
- Assert.Equal("existing-queue", queueName);
- Assert.Equal([1, 2], messageBody);
- migrationHistory.MatchInlineSnapshot(
- """
- [
- "2026-03-06_AddConsumerManagement",
- "2026-03-06_AddTransportIndex",
- "2026-03-06_InitialSchema",
- "future-migration"
- ]
- """);
- }
-
- [Theory]
- [InlineData(false, false)]
- [InlineData(true, true)]
- [InlineData(false, true)]
- public async Task MigrateAsync_Should_ApplyMigrationsOnce_When_CallersRunConcurrently(
- bool firstUsesScript,
- bool secondUsesScript)
- {
- // arrange
- await using var database = await fixture.CreateDatabaseAsync();
- await using var firstConnection = await OpenConnectionAsync(database.ConnectionString);
- await using var secondConnection = await OpenConnectionAsync(database.ConnectionString);
- var options = new PostgresSchemaOptions();
-
- // act
- var firstMigration = ApplyMigrationsAsync(firstConnection, options, firstUsesScript);
- var secondMigration = ApplyMigrationsAsync(secondConnection, options, secondUsesScript);
- await Task.WhenAll(firstMigration, secondMigration);
-
- // assert
- var migrationHistory = await ReadMigrationHistoryAsync(firstConnection);
-
- Assert.Equal(3, migrationHistory.Length);
+ Assert.Equal(
+ ["2026-03-06_AddConsumerManagement", "2026-03-06_AddTransportIndex", "2026-03-06_InitialSchema"],
+ migrationHistory);
+ Snapshot.Create()
+ .Add(await ReadSchemaAsync(connection), "Schema")
+ .MatchMarkdownSnapshot();
}
[Theory]
[InlineData(false)]
[InlineData(true)]
- public async Task MigrateAsync_Should_RollBack_When_MigrationFails(bool useMigrationScript)
+ public async Task MigrateAsync_Should_LeaveResourcesUnchanged_When_RunTwice(bool useMigrationScript)
{
// arrange
await using var database = await fixture.CreateDatabaseAsync();
await using var connection = await OpenConnectionAsync(database.ConnectionString);
- var options = new PostgresSchemaOptions();
- await ExecuteNonQueryAsync(connection, "CREATE VIEW public.mocha_message AS SELECT 1 AS sentinel;");
+ await ApplyMigrationsAsync(connection, useMigrationScript);
+ var schema = await ReadSchemaAsync(connection);
+ var migrationHistory = await ReadMigrationHistoryAsync(connection);
// act
- var exception = await Assert.ThrowsAsync(
- () => ApplyMigrationsAsync(connection, options, useMigrationScript));
+ await ApplyMigrationsAsync(connection, useMigrationScript);
// assert
- await using var verification = await OpenConnectionAsync(database.ConnectionString);
- var tableCount = await ExecuteScalarAsync(verification,
- "SELECT count(*) FROM pg_tables WHERE schemaname = 'public';");
- var sequenceExists = await ExecuteScalarAsync(verification,
- "SELECT to_regclass('public.mocha_topology_seq') IS NOT NULL;");
-
- Assert.Equal(PostgresErrorCodes.WrongObjectType, exception.SqlState);
- Assert.Equal(0, tableCount);
- Assert.False(sequenceExists);
+ var repeatedSchema = await ReadSchemaAsync(connection);
+ var repeatedMigrationHistory = await ReadMigrationHistoryAsync(connection);
+
+ Assert.Equal(schema, repeatedSchema);
+ Assert.Equal(migrationHistory, repeatedMigrationHistory);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
- public async Task MigrateAsync_Should_AllowRetry_When_PreviousMigrationFailed(bool useMigrationScript)
+ public async Task MigrateAsync_Should_ApplyPendingMigrations_When_OnlyInitialMigrationIsRecorded(bool useMigrationScript)
{
// arrange
await using var database = await fixture.CreateDatabaseAsync();
await using var connection = await OpenConnectionAsync(database.ConnectionString);
- var options = new PostgresSchemaOptions();
- await ExecuteNonQueryAsync(connection, "CREATE VIEW public.mocha_message AS SELECT 1 AS sentinel;");
- await Assert.ThrowsAsync(() => ApplyMigrationsAsync(connection, options, useMigrationScript));
-
- if (useMigrationScript)
- {
- await ExecuteNonQueryAsync(connection, "ROLLBACK;");
- }
-
- await ExecuteNonQueryAsync(connection, "DROP VIEW public.mocha_message;");
-
- // act
- await ApplyMigrationsAsync(connection, options, useMigrationScript);
-
- // assert
- var migrationHistory = await ReadMigrationHistoryAsync(connection);
-
- Assert.Equal(3, migrationHistory.Length);
- }
-
- [Fact]
- public async Task MigrateAsync_Should_AllowRetry_When_CancelledWhileWaitingForMigrationLock()
- {
- // arrange
- await using var database = await fixture.CreateDatabaseAsync();
- await using var blocker = await OpenConnectionAsync(database.ConnectionString);
- await using var connection = await OpenConnectionAsync(database.ConnectionString);
- await using var transaction = await blocker.BeginTransactionAsync(CancellationToken);
- await ExecuteNonQueryAsync(blocker, "SELECT pg_advisory_xact_lock(958913715);");
- using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken);
- var options = new PostgresSchemaOptions();
+ await ExecuteNonQueryAsync(connection, PostgresSchemaSql.InitialSchema(_schemaOptions));
+ await ExecuteNonQueryAsync(connection,
+ """
+ CREATE TABLE messaging.mocha_migrations (
+ migration_id text PRIMARY KEY,
+ applied_on timestamptz NOT NULL DEFAULT now());
+ INSERT INTO messaging.mocha_migrations (migration_id) VALUES ('2026-03-06_InitialSchema');
+ """);
// act
- var migration = PostgresTransportSchema.MigrateAsync(connection, options, cancellation.Token);
- await WaitForMigrationLockAsync(blocker, connection.ProcessID);
- await cancellation.CancelAsync();
- var exception = await Record.ExceptionAsync(() => migration);
- var historyExists = await ExecuteScalarAsync(connection,
- "SELECT to_regclass('public.mocha_migrations') IS NOT NULL;");
-
- await transaction.RollbackAsync(CancellationToken);
- await PostgresTransportSchema.MigrateAsync(connection, options, CancellationToken);
+ await ApplyMigrationsAsync(connection, useMigrationScript);
// assert
var migrationHistory = await ReadMigrationHistoryAsync(connection);
- Assert.IsAssignableFrom(exception);
- Assert.False(historyExists);
- Assert.Equal(3, migrationHistory.Length);
- }
-
- [Theory]
- [InlineData("messaging", "bus_")]
- [InlineData("MixedCase", "Bus_")]
- [InlineData("\"odd.schema'\\\"\"$mocha_migration$\"", "bus_")]
- public async Task MigrateAsync_Should_UseRuntimeNames_When_CustomNamesConfigured(string schema, string prefix)
- {
- // arrange
- await using var database = await fixture.CreateDatabaseAsync();
- await using var connection = await OpenConnectionAsync(database.ConnectionString);
- var options = new PostgresSchemaOptions { Schema = schema, TablePrefix = prefix };
-
- // act
- await ApplyMigrationsAsync(connection, options, useMigrationScript: true);
- await ApplyMigrationsAsync(connection, options, useMigrationScript: false);
-
- // assert
- var migrationCount = await ExecuteScalarAsync(connection,
- $"SELECT count(*) FROM {options.MigrationsTable};");
- var queueCount = await ExecuteScalarAsync(connection,
- $"SELECT count(*) FROM {options.QueueTable};");
-
- Assert.Equal(3, migrationCount);
- Assert.Equal(0, queueCount);
- }
-
- [Fact]
- public async Task MigrateAsync_Should_RejectClosedConnection_When_CallerHasNotOpenedIt()
- {
- // arrange
- await using var connection = new NpgsqlConnection(fixture.ConnectionString);
-
- // act
- var exception = await Assert.ThrowsAsync(
- () => PostgresTransportSchema.MigrateAsync(connection, new PostgresSchemaOptions(), CancellationToken));
-
- // assert
- Assert.Equal("Transport migration requires an open PostgreSQL connection.", exception.Message);
- Assert.Equal(ConnectionState.Closed, connection.State);
+ Assert.Equal(
+ ["2026-03-06_AddConsumerManagement", "2026-03-06_AddTransportIndex", "2026-03-06_InitialSchema"],
+ migrationHistory);
+ Snapshot.Create()
+ .Add(await ReadSchemaAsync(connection), "Schema")
+ .MatchMarkdownSnapshot();
}
[Fact]
- public async Task MigrateAsync_Should_RejectTransaction_When_ConnectionHasActiveTransaction()
+ public async Task PublishAsync_Should_Deliver_When_ScriptAppliedAndAutoMigrateDisabled()
{
// arrange
+ var recorder = new MessageRecorder();
await using var database = await fixture.CreateDatabaseAsync();
await using var connection = await OpenConnectionAsync(database.ConnectionString);
- await using var transaction = await connection.BeginTransactionAsync(CancellationToken);
- var options = new PostgresSchemaOptions();
-
- // act
- var exception = await Assert.ThrowsAsync(
- () => PostgresTransportSchema.MigrateAsync(connection, options, CancellationToken));
-
- // assert
- var historyExists = await ExecuteScalarAsync(connection,
- "SELECT to_regclass('public.mocha_migrations') IS NOT NULL;");
+ var script = PostgresTransportSchema.GenerateMigrationsSql(_schemaOptions);
+ await ExecuteNonQueryAsync(connection, script);
+ var role = $"mocha_runtime_{Guid.NewGuid():N}";
+ await ExecuteNonQueryAsync(connection, $"CREATE ROLE {role} LOGIN PASSWORD 'runtime-test';");
- Assert.Equal("Transport migration requires a connection without an active or ambient transaction.", exception.Message);
- Assert.False(historyExists);
- Assert.Same(connection, transaction.Connection);
- }
-
- [Fact]
- public async Task MigrateAsync_Should_RejectTransaction_When_AmbientTransactionExists()
- {
- // arrange
- await using var database = await fixture.CreateDatabaseAsync();
- await using var connection = await OpenConnectionAsync(database.ConnectionString);
- using var transaction = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled);
- var options = new PostgresSchemaOptions();
-
- // act
- var exception = await Assert.ThrowsAsync(
- () => PostgresTransportSchema.MigrateAsync(connection, options, CancellationToken));
-
- // assert
- var historyExists = await ExecuteScalarAsync(connection,
- "SELECT to_regclass('public.mocha_migrations') IS NOT NULL;");
-
- Assert.Equal("Transport migration requires a connection without an active or ambient transaction.", exception.Message);
- Assert.False(historyExists);
- Assert.Equal(ConnectionState.Open, connection.State);
- }
-
- [Fact]
- public async Task MigrateAsync_Should_PreserveHistory_When_DatabaseClonedFromTemplate()
- {
- // arrange
- await using var template = await fixture.CreateDatabaseAsync();
- string[] templateHistory;
- await using (var connection = await OpenConnectionAsync(template.ConnectionString))
+ try
+ {
+ await ExecuteNonQueryAsync(connection,
+ $"""
+ GRANT CONNECT ON DATABASE "{database.DatabaseName}" TO {role};
+ GRANT USAGE ON SCHEMA messaging TO {role};
+ GRANT SELECT, INSERT, UPDATE, DELETE ON
+ messaging.mocha_topic, messaging.mocha_queue, messaging.mocha_queue_subscription,
+ messaging.mocha_message, messaging.mocha_consumers TO {role};
+ GRANT USAGE ON SEQUENCE messaging.mocha_topology_seq TO {role};
+ """);
+ var settings = new NpgsqlConnectionStringBuilder(database.ConnectionString)
+ {
+ Username = role,
+ Password = "runtime-test",
+ Pooling = false
+ };
+ await using var bus = await new ServiceCollection()
+ .AddSingleton(recorder)
+ .AddMessageBus()
+ .AddEventHandler()
+ .AddPostgres(transport =>
+ {
+ transport.ConnectionString(settings.ConnectionString).AutoMigrate(false);
+ transport.ExtendWith(extension => extension.Configuration.SchemaOptions = _schemaOptions);
+ })
+ .BuildTestBusAsync();
+ using var scope = bus.Provider.CreateScope();
+ var messageBus = scope.ServiceProvider.GetRequiredService();
+
+ // act
+ await messageBus.PublishAsync(new OrderCreated { OrderId = "ORD-1" }, CancellationToken);
+ var delivered = await recorder.WaitAsync(TimeSpan.FromSeconds(30));
+
+ // assert
+ Assert.True(delivered, "Handler did not receive the event.");
+ var order = Assert.Single(recorder.Messages.Cast());
+ Assert.Equal("ORD-1", order.OrderId);
+ }
+ finally
{
- await PostgresTransportSchema.MigrateAsync(connection, new PostgresSchemaOptions(), CancellationToken);
- templateHistory = await ReadMigrationHistoryAsync(connection);
+ await ExecuteNonQueryAsync(connection, $"DROP OWNED BY {role}; DROP ROLE {role};");
}
-
- // act
- await using var clone = await CloneDatabaseAsync(template);
-
- // assert
- await using var verification = await OpenConnectionAsync(clone.ConnectionString);
- var migrationHistory = await ReadMigrationHistoryAsync(verification);
- var consumerCount = await ExecuteScalarAsync(verification, "SELECT count(*) FROM public.mocha_consumers;");
- var queueCount = await ExecuteScalarAsync(verification, "SELECT count(*) FROM public.mocha_queue;");
-
- Assert.Equal(0, consumerCount);
- Assert.Equal(0, queueCount);
- Assert.Equal(templateHistory, migrationHistory);
}
[Fact]
- public async Task MigrateAsync_Should_LeaveDatabaseUnchanged_When_CancelledBeforeExecution()
+ public async Task MigrateAsync_Should_RollBack_When_MigrationFails()
{
// arrange
await using var database = await fixture.CreateDatabaseAsync();
await using var connection = await OpenConnectionAsync(database.ConnectionString);
- using var cancellation = new CancellationTokenSource();
- await cancellation.CancelAsync();
-
- // act
- await Assert.ThrowsAnyAsync(
- () => PostgresTransportSchema.MigrateAsync(connection, new PostgresSchemaOptions(), cancellation.Token));
-
- // assert
- var tableCount = await ExecuteScalarAsync(connection,
- "SELECT count(*) FROM pg_tables WHERE schemaname = 'public';");
-
- Assert.Equal(0, tableCount);
- Assert.Equal(ConnectionState.Open, connection.State);
- }
-
- [Theory]
- [InlineData(false)]
- [InlineData(true)]
- public async Task MigrateAsync_Should_RejectReplay_When_HistoryWasErased(bool useMigrationScript)
- {
- // arrange
- await using var database = await fixture.CreateDatabaseAsync();
- await using var connection = await OpenConnectionAsync(database.ConnectionString);
- var options = new PostgresSchemaOptions();
- await PostgresTransportSchema.MigrateAsync(connection, options, CancellationToken);
- await ExecuteNonQueryAsync(connection, "TRUNCATE public.mocha_migrations;");
+ await ExecuteNonQueryAsync(connection,
+ """
+ CREATE SCHEMA messaging;
+ CREATE VIEW messaging.mocha_message AS SELECT 1 AS sentinel;
+ """);
+ var schema = await ReadSchemaAsync(connection);
// act
- var exception = await Assert.ThrowsAsync(() => ApplyMigrationsAsync(connection, options, useMigrationScript));
- if (useMigrationScript)
- {
- await ExecuteNonQueryAsync(connection, "ROLLBACK;");
- }
+ await Assert.ThrowsAsync(
+ () => PostgresTransportSchema.MigrateAsync(connection, _schemaOptions, CancellationToken));
// assert
- var migrationCount = await ExecuteScalarAsync(connection,
- "SELECT count(*) FROM public.mocha_migrations;");
+ var schemaAfterFailure = await ReadSchemaAsync(connection);
- Assert.Equal(PostgresErrorCodes.DuplicateTable, exception.SqlState);
- Assert.Equal(0, migrationCount);
+ Assert.Equal(schema, schemaAfterFailure);
}
private static async Task OpenConnectionAsync(string connectionString)
@@ -383,18 +251,14 @@ private static async Task OpenConnectionAsync(string connectio
return connection;
}
- private static Task ApplyMigrationsAsync(
- NpgsqlConnection connection,
- PostgresSchemaOptions options,
- bool useMigrationScript)
+ private Task ApplyMigrationsAsync(NpgsqlConnection connection, bool useMigrationScript)
{
if (useMigrationScript)
{
- var sql = PostgresTransportSchema.GenerateMigrationsSql(options);
- return ExecuteNonQueryAsync(connection, sql);
+ return ExecuteNonQueryAsync(connection, PostgresTransportSchema.GenerateMigrationsSql(_schemaOptions));
}
- return PostgresTransportSchema.MigrateAsync(connection, options, CancellationToken);
+ return PostgresTransportSchema.MigrateAsync(connection, _schemaOptions, CancellationToken);
}
private static async Task ExecuteNonQueryAsync(NpgsqlConnection connection, string sql)
@@ -413,10 +277,8 @@ private static async Task ExecuteScalarAsync(NpgsqlConnection connection,
}
private static Task ReadMigrationHistoryAsync(NpgsqlConnection connection)
- {
- const string sql = "SELECT array_agg(migration_id ORDER BY migration_id) FROM public.mocha_migrations;";
- return ExecuteScalarAsync(connection, sql);
- }
+ => ExecuteScalarAsync(connection,
+ "SELECT array_agg(migration_id ORDER BY migration_id) FROM messaging.mocha_migrations;");
private static Task ReadSchemaAsync(NpgsqlConnection connection)
{
@@ -425,56 +287,18 @@ private static Task ReadSchemaAsync(NpgsqlConnection connection)
SELECT array_agg(description ORDER BY description)
FROM (
SELECT 'relation: ' || relname || ' ' || relkind::text AS description
- FROM pg_class WHERE relnamespace = 'public'::regnamespace
+ FROM pg_class WHERE relnamespace = 'messaging'::regnamespace
UNION ALL
SELECT 'column: ' || table_name || '.' || column_name || ' ' || data_type || ' ' || is_nullable
- FROM information_schema.columns WHERE table_schema = 'public'
+ FROM information_schema.columns WHERE table_schema = 'messaging'
UNION ALL
SELECT 'constraint: ' || conname || ' ' || pg_get_constraintdef(oid)
- FROM pg_constraint WHERE connamespace = 'public'::regnamespace
+ FROM pg_constraint WHERE connamespace = 'messaging'::regnamespace
UNION ALL
- SELECT 'index: ' || indexdef FROM pg_indexes WHERE schemaname = 'public'
+ SELECT 'index: ' || indexdef FROM pg_indexes WHERE schemaname = 'messaging'
) state;
""";
return ExecuteScalarAsync(connection, sql);
}
-
- private static async Task WaitForMigrationLockAsync(NpgsqlConnection connection, int processId)
- {
- using var timeout = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken);
- timeout.CancelAfter(TimeSpan.FromSeconds(10));
- await using var command = connection.CreateCommand();
- command.CommandText =
- """
- SELECT EXISTS (
- SELECT FROM pg_locks
- WHERE pid = @process_id AND locktype = 'advisory' AND NOT granted);
- """;
- command.Parameters.AddWithValue("process_id", processId);
-
- while (true)
- {
- var waitingForLock = await command.ExecuteScalarAsync(timeout.Token);
- if (waitingForLock is true)
- {
- return;
- }
-
- await Task.Delay(20, timeout.Token);
- }
- }
-
- private async Task CloneDatabaseAsync(DatabaseContext template)
- {
- var databaseName = $"mocha_clone_{Guid.NewGuid():N}";
- await using var connection = await OpenConnectionAsync(fixture.ConnectionString);
- await ExecuteNonQueryAsync(connection, $"CREATE DATABASE \"{databaseName}\" TEMPLATE \"{template.DatabaseName}\";");
-
- var settings = new NpgsqlConnectionStringBuilder(template.ConnectionString)
- {
- Database = databaseName
- };
- return new DatabaseContext(fixture, databaseName, settings.ConnectionString);
- }
}
diff --git a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/__snapshots__/PostgresMigrationScriptTests.GenerateMigrationsSql_Should_ReturnDeterministicSql_When_UsingDefaults.snap b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/__snapshots__/PostgresMigrationScriptTests.GenerateMigrationsSql_Should_ReturnDeterministicSql_When_UsingDefaults.snap
deleted file mode 100644
index 3bca0e50f2e..00000000000
--- a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/__snapshots__/PostgresMigrationScriptTests.GenerateMigrationsSql_Should_ReturnDeterministicSql_When_UsingDefaults.snap
+++ /dev/null
@@ -1,118 +0,0 @@
-BEGIN;
-SELECT pg_advisory_xact_lock(958913715);
-CREATE SCHEMA IF NOT EXISTS public;
-
-CREATE TABLE IF NOT EXISTS public.mocha_migrations
-(
- migration_id text NOT NULL PRIMARY KEY,
- applied_on timestamptz NOT NULL DEFAULT (now() at time zone 'utc')
-);
-
-DO $mocha_migration$
-BEGIN
- IF NOT EXISTS (SELECT 1 FROM public.mocha_migrations WHERE migration_id = E'2026-03-06_InitialSchema') THEN
- CREATE SCHEMA IF NOT EXISTS public;
-
- CREATE SEQUENCE IF NOT EXISTS public.mocha_topology_seq AS bigint;
-
- CREATE TABLE IF NOT EXISTS public.mocha_topic
- (
- id bigint NOT NULL PRIMARY KEY DEFAULT nextval(E'public.mocha_topology_seq'),
- updated timestamptz NOT NULL DEFAULT (now() at time zone 'utc'),
- name text NOT NULL
- );
-
- CREATE UNIQUE INDEX IF NOT EXISTS mocha_topic_uqx ON public.mocha_topic (name) INCLUDE (id);
- ALTER TABLE public.mocha_topic ADD CONSTRAINT mocha_unique_topic UNIQUE USING INDEX mocha_topic_uqx;
-
- CREATE TABLE IF NOT EXISTS public.mocha_queue
- (
- id bigint NOT NULL PRIMARY KEY DEFAULT nextval(E'public.mocha_topology_seq'),
- updated timestamptz NOT NULL DEFAULT (now() at time zone 'utc'),
- name text NOT NULL
- );
-
- CREATE UNIQUE INDEX IF NOT EXISTS mocha_queue_uqx ON public.mocha_queue (name) INCLUDE (id);
- ALTER TABLE public.mocha_queue ADD CONSTRAINT mocha_unique_queue UNIQUE USING INDEX mocha_queue_uqx;
-
- CREATE TABLE IF NOT EXISTS public.mocha_queue_subscription
- (
- id bigint NOT NULL PRIMARY KEY DEFAULT nextval(E'public.mocha_topology_seq'),
- updated timestamptz NOT NULL DEFAULT (now() at time zone 'utc'),
- source_id bigint NOT NULL REFERENCES public.mocha_topic (id) ON DELETE CASCADE,
- destination_id bigint NOT NULL REFERENCES public.mocha_queue (id) ON DELETE CASCADE
- );
-
- CREATE UNIQUE INDEX IF NOT EXISTS mocha_queue_subscription_uqx
- ON public.mocha_queue_subscription (source_id, destination_id);
- ALTER TABLE public.mocha_queue_subscription
- ADD CONSTRAINT mocha_unique_queue_subscription UNIQUE USING INDEX mocha_queue_subscription_uqx;
-
- CREATE INDEX IF NOT EXISTS mocha_queue_subscription_source_ndx
- ON public.mocha_queue_subscription (source_id) INCLUDE (id, destination_id);
- CREATE INDEX IF NOT EXISTS mocha_queue_subscription_dest_ndx
- ON public.mocha_queue_subscription (destination_id) INCLUDE (id, source_id);
-
- CREATE TABLE IF NOT EXISTS public.mocha_message
- (
- transport_message_id uuid NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
- body bytea NOT NULL,
- headers jsonb,
- queue_id bigint NOT NULL REFERENCES public.mocha_queue (id) ON DELETE CASCADE,
- sent_time timestamptz NOT NULL DEFAULT (now() at time zone 'utc'),
- scheduled_time timestamptz,
- expiration_time timestamptz,
- delivery_count int NOT NULL DEFAULT 0,
- max_delivery_count int NOT NULL DEFAULT 10,
- last_delivered timestamptz,
- consumer_id uuid,
- error_reason jsonb
- );
-
- CREATE INDEX IF NOT EXISTS mocha_message_queue_ndx
- ON public.mocha_message (queue_id) INCLUDE (transport_message_id);
- INSERT INTO public.mocha_migrations (migration_id) VALUES (E'2026-03-06_InitialSchema');
- END IF;
-END;
-$mocha_migration$;
-
-DO $mocha_migration$
-BEGIN
- IF NOT EXISTS (SELECT 1 FROM public.mocha_migrations WHERE migration_id = E'2026-03-06_AddTransportIndex') THEN
- CREATE INDEX IF NOT EXISTS mocha_message_transport_queue_ndx
- ON public.mocha_message (transport_message_id, queue_id);
-
- CREATE INDEX IF NOT EXISTS mocha_message_expiration_scheduled_ndx
- ON public.mocha_message (expiration_time, scheduled_time);
-
- CREATE INDEX IF NOT EXISTS mocha_message_sent_time_ndx
- ON public.mocha_message (sent_time);
- INSERT INTO public.mocha_migrations (migration_id) VALUES (E'2026-03-06_AddTransportIndex');
- END IF;
-END;
-$mocha_migration$;
-
-DO $mocha_migration$
-BEGIN
- IF NOT EXISTS (SELECT 1 FROM public.mocha_migrations WHERE migration_id = E'2026-03-06_AddConsumerManagement') THEN
- CREATE TABLE IF NOT EXISTS public.mocha_consumers
- (
- id uuid NOT NULL PRIMARY KEY,
- service_name text NOT NULL,
- created_at timestamptz NOT NULL DEFAULT now(),
- updated_at timestamptz NOT NULL DEFAULT now()
- );
-
- ALTER TABLE public.mocha_queue
- ADD COLUMN IF NOT EXISTS consumer_id uuid REFERENCES public.mocha_consumers(id) ON DELETE CASCADE;
-
- CREATE INDEX IF NOT EXISTS mocha_consumers_updated_at_ndx
- ON public.mocha_consumers (updated_at);
-
- CREATE INDEX IF NOT EXISTS mocha_queue_consumer_id_ndx
- ON public.mocha_queue (consumer_id);
- INSERT INTO public.mocha_migrations (migration_id) VALUES (E'2026-03-06_AddConsumerManagement');
- END IF;
-END;
-$mocha_migration$;
-COMMIT;
diff --git a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/__snapshots__/PostgresTransportSchemaTests.MigrateAsync_Should_ApplyPendingMigrations_When_OnlyInitialMigrationIsRecorded.md b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/__snapshots__/PostgresTransportSchemaTests.MigrateAsync_Should_ApplyPendingMigrations_When_OnlyInitialMigrationIsRecorded.md
new file mode 100644
index 00000000000..a0ac7ea13cf
--- /dev/null
+++ b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/__snapshots__/PostgresTransportSchemaTests.MigrateAsync_Should_ApplyPendingMigrations_When_OnlyInitialMigrationIsRecorded.md
@@ -0,0 +1,89 @@
+# MigrateAsync_Should_ApplyPendingMigrations_When_OnlyInitialMigrationIsRecorded
+
+```json
+[
+ "column: mocha_consumers.created_at timestamp with time zone NO",
+ "column: mocha_consumers.id uuid NO",
+ "column: mocha_consumers.service_name text NO",
+ "column: mocha_consumers.updated_at timestamp with time zone NO",
+ "column: mocha_message.body bytea NO",
+ "column: mocha_message.consumer_id uuid YES",
+ "column: mocha_message.delivery_count integer NO",
+ "column: mocha_message.error_reason jsonb YES",
+ "column: mocha_message.expiration_time timestamp with time zone YES",
+ "column: mocha_message.headers jsonb YES",
+ "column: mocha_message.last_delivered timestamp with time zone YES",
+ "column: mocha_message.max_delivery_count integer NO",
+ "column: mocha_message.queue_id bigint NO",
+ "column: mocha_message.scheduled_time timestamp with time zone YES",
+ "column: mocha_message.sent_time timestamp with time zone NO",
+ "column: mocha_message.transport_message_id uuid NO",
+ "column: mocha_migrations.applied_on timestamp with time zone NO",
+ "column: mocha_migrations.migration_id text NO",
+ "column: mocha_queue.consumer_id uuid YES",
+ "column: mocha_queue.id bigint NO",
+ "column: mocha_queue.name text NO",
+ "column: mocha_queue.updated timestamp with time zone NO",
+ "column: mocha_queue_subscription.destination_id bigint NO",
+ "column: mocha_queue_subscription.id bigint NO",
+ "column: mocha_queue_subscription.source_id bigint NO",
+ "column: mocha_queue_subscription.updated timestamp with time zone NO",
+ "column: mocha_topic.id bigint NO",
+ "column: mocha_topic.name text NO",
+ "column: mocha_topic.updated timestamp with time zone NO",
+ "constraint: mocha_consumers_pkey PRIMARY KEY (id)",
+ "constraint: mocha_message_pkey PRIMARY KEY (transport_message_id)",
+ "constraint: mocha_message_queue_id_fkey FOREIGN KEY (queue_id) REFERENCES messaging.mocha_queue(id) ON DELETE CASCADE",
+ "constraint: mocha_migrations_pkey PRIMARY KEY (migration_id)",
+ "constraint: mocha_queue_consumer_id_fkey FOREIGN KEY (consumer_id) REFERENCES messaging.mocha_consumers(id) ON DELETE CASCADE",
+ "constraint: mocha_queue_pkey PRIMARY KEY (id)",
+ "constraint: mocha_queue_subscription_destination_id_fkey FOREIGN KEY (destination_id) REFERENCES messaging.mocha_queue(id) ON DELETE CASCADE",
+ "constraint: mocha_queue_subscription_pkey PRIMARY KEY (id)",
+ "constraint: mocha_queue_subscription_source_id_fkey FOREIGN KEY (source_id) REFERENCES messaging.mocha_topic(id) ON DELETE CASCADE",
+ "constraint: mocha_topic_pkey PRIMARY KEY (id)",
+ "constraint: mocha_unique_queue UNIQUE (name) INCLUDE (id)",
+ "constraint: mocha_unique_queue_subscription UNIQUE (source_id, destination_id)",
+ "constraint: mocha_unique_topic UNIQUE (name) INCLUDE (id)",
+ "index: CREATE INDEX mocha_consumers_updated_at_ndx ON messaging.mocha_consumers USING btree (updated_at)",
+ "index: CREATE INDEX mocha_message_expiration_scheduled_ndx ON messaging.mocha_message USING btree (expiration_time, scheduled_time)",
+ "index: CREATE INDEX mocha_message_queue_ndx ON messaging.mocha_message USING btree (queue_id) INCLUDE (transport_message_id)",
+ "index: CREATE INDEX mocha_message_sent_time_ndx ON messaging.mocha_message USING btree (sent_time)",
+ "index: CREATE INDEX mocha_message_transport_queue_ndx ON messaging.mocha_message USING btree (transport_message_id, queue_id)",
+ "index: CREATE INDEX mocha_queue_consumer_id_ndx ON messaging.mocha_queue USING btree (consumer_id)",
+ "index: CREATE INDEX mocha_queue_subscription_dest_ndx ON messaging.mocha_queue_subscription USING btree (destination_id) INCLUDE (id, source_id)",
+ "index: CREATE INDEX mocha_queue_subscription_source_ndx ON messaging.mocha_queue_subscription USING btree (source_id) INCLUDE (id, destination_id)",
+ "index: CREATE UNIQUE INDEX mocha_consumers_pkey ON messaging.mocha_consumers USING btree (id)",
+ "index: CREATE UNIQUE INDEX mocha_message_pkey ON messaging.mocha_message USING btree (transport_message_id)",
+ "index: CREATE UNIQUE INDEX mocha_migrations_pkey ON messaging.mocha_migrations USING btree (migration_id)",
+ "index: CREATE UNIQUE INDEX mocha_queue_pkey ON messaging.mocha_queue USING btree (id)",
+ "index: CREATE UNIQUE INDEX mocha_queue_subscription_pkey ON messaging.mocha_queue_subscription USING btree (id)",
+ "index: CREATE UNIQUE INDEX mocha_topic_pkey ON messaging.mocha_topic USING btree (id)",
+ "index: CREATE UNIQUE INDEX mocha_unique_queue ON messaging.mocha_queue USING btree (name) INCLUDE (id)",
+ "index: CREATE UNIQUE INDEX mocha_unique_queue_subscription ON messaging.mocha_queue_subscription USING btree (source_id, destination_id)",
+ "index: CREATE UNIQUE INDEX mocha_unique_topic ON messaging.mocha_topic USING btree (name) INCLUDE (id)",
+ "relation: mocha_consumers r",
+ "relation: mocha_consumers_pkey i",
+ "relation: mocha_consumers_updated_at_ndx i",
+ "relation: mocha_message r",
+ "relation: mocha_message_expiration_scheduled_ndx i",
+ "relation: mocha_message_pkey i",
+ "relation: mocha_message_queue_ndx i",
+ "relation: mocha_message_sent_time_ndx i",
+ "relation: mocha_message_transport_queue_ndx i",
+ "relation: mocha_migrations r",
+ "relation: mocha_migrations_pkey i",
+ "relation: mocha_queue r",
+ "relation: mocha_queue_consumer_id_ndx i",
+ "relation: mocha_queue_pkey i",
+ "relation: mocha_queue_subscription r",
+ "relation: mocha_queue_subscription_dest_ndx i",
+ "relation: mocha_queue_subscription_pkey i",
+ "relation: mocha_queue_subscription_source_ndx i",
+ "relation: mocha_topic r",
+ "relation: mocha_topic_pkey i",
+ "relation: mocha_topology_seq S",
+ "relation: mocha_unique_queue i",
+ "relation: mocha_unique_queue_subscription i",
+ "relation: mocha_unique_topic i"
+]
+```
diff --git a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/__snapshots__/PostgresTransportSchemaTests.MigrateAsync_Should_CreateResources_When_DatabaseIsEmpty.md b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/__snapshots__/PostgresTransportSchemaTests.MigrateAsync_Should_CreateResources_When_DatabaseIsEmpty.md
new file mode 100644
index 00000000000..926c5452ce5
--- /dev/null
+++ b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/__snapshots__/PostgresTransportSchemaTests.MigrateAsync_Should_CreateResources_When_DatabaseIsEmpty.md
@@ -0,0 +1,89 @@
+# MigrateAsync_Should_CreateResources_When_DatabaseIsEmpty
+
+```json
+[
+ "column: mocha_consumers.created_at timestamp with time zone NO",
+ "column: mocha_consumers.id uuid NO",
+ "column: mocha_consumers.service_name text NO",
+ "column: mocha_consumers.updated_at timestamp with time zone NO",
+ "column: mocha_message.body bytea NO",
+ "column: mocha_message.consumer_id uuid YES",
+ "column: mocha_message.delivery_count integer NO",
+ "column: mocha_message.error_reason jsonb YES",
+ "column: mocha_message.expiration_time timestamp with time zone YES",
+ "column: mocha_message.headers jsonb YES",
+ "column: mocha_message.last_delivered timestamp with time zone YES",
+ "column: mocha_message.max_delivery_count integer NO",
+ "column: mocha_message.queue_id bigint NO",
+ "column: mocha_message.scheduled_time timestamp with time zone YES",
+ "column: mocha_message.sent_time timestamp with time zone NO",
+ "column: mocha_message.transport_message_id uuid NO",
+ "column: mocha_migrations.applied_on timestamp with time zone NO",
+ "column: mocha_migrations.migration_id text NO",
+ "column: mocha_queue.consumer_id uuid YES",
+ "column: mocha_queue.id bigint NO",
+ "column: mocha_queue.name text NO",
+ "column: mocha_queue.updated timestamp with time zone NO",
+ "column: mocha_queue_subscription.destination_id bigint NO",
+ "column: mocha_queue_subscription.id bigint NO",
+ "column: mocha_queue_subscription.source_id bigint NO",
+ "column: mocha_queue_subscription.updated timestamp with time zone NO",
+ "column: mocha_topic.id bigint NO",
+ "column: mocha_topic.name text NO",
+ "column: mocha_topic.updated timestamp with time zone NO",
+ "constraint: mocha_consumers_pkey PRIMARY KEY (id)",
+ "constraint: mocha_message_pkey PRIMARY KEY (transport_message_id)",
+ "constraint: mocha_message_queue_id_fkey FOREIGN KEY (queue_id) REFERENCES messaging.mocha_queue(id) ON DELETE CASCADE",
+ "constraint: mocha_migrations_pkey PRIMARY KEY (migration_id)",
+ "constraint: mocha_queue_consumer_id_fkey FOREIGN KEY (consumer_id) REFERENCES messaging.mocha_consumers(id) ON DELETE CASCADE",
+ "constraint: mocha_queue_pkey PRIMARY KEY (id)",
+ "constraint: mocha_queue_subscription_destination_id_fkey FOREIGN KEY (destination_id) REFERENCES messaging.mocha_queue(id) ON DELETE CASCADE",
+ "constraint: mocha_queue_subscription_pkey PRIMARY KEY (id)",
+ "constraint: mocha_queue_subscription_source_id_fkey FOREIGN KEY (source_id) REFERENCES messaging.mocha_topic(id) ON DELETE CASCADE",
+ "constraint: mocha_topic_pkey PRIMARY KEY (id)",
+ "constraint: mocha_unique_queue UNIQUE (name) INCLUDE (id)",
+ "constraint: mocha_unique_queue_subscription UNIQUE (source_id, destination_id)",
+ "constraint: mocha_unique_topic UNIQUE (name) INCLUDE (id)",
+ "index: CREATE INDEX mocha_consumers_updated_at_ndx ON messaging.mocha_consumers USING btree (updated_at)",
+ "index: CREATE INDEX mocha_message_expiration_scheduled_ndx ON messaging.mocha_message USING btree (expiration_time, scheduled_time)",
+ "index: CREATE INDEX mocha_message_queue_ndx ON messaging.mocha_message USING btree (queue_id) INCLUDE (transport_message_id)",
+ "index: CREATE INDEX mocha_message_sent_time_ndx ON messaging.mocha_message USING btree (sent_time)",
+ "index: CREATE INDEX mocha_message_transport_queue_ndx ON messaging.mocha_message USING btree (transport_message_id, queue_id)",
+ "index: CREATE INDEX mocha_queue_consumer_id_ndx ON messaging.mocha_queue USING btree (consumer_id)",
+ "index: CREATE INDEX mocha_queue_subscription_dest_ndx ON messaging.mocha_queue_subscription USING btree (destination_id) INCLUDE (id, source_id)",
+ "index: CREATE INDEX mocha_queue_subscription_source_ndx ON messaging.mocha_queue_subscription USING btree (source_id) INCLUDE (id, destination_id)",
+ "index: CREATE UNIQUE INDEX mocha_consumers_pkey ON messaging.mocha_consumers USING btree (id)",
+ "index: CREATE UNIQUE INDEX mocha_message_pkey ON messaging.mocha_message USING btree (transport_message_id)",
+ "index: CREATE UNIQUE INDEX mocha_migrations_pkey ON messaging.mocha_migrations USING btree (migration_id)",
+ "index: CREATE UNIQUE INDEX mocha_queue_pkey ON messaging.mocha_queue USING btree (id)",
+ "index: CREATE UNIQUE INDEX mocha_queue_subscription_pkey ON messaging.mocha_queue_subscription USING btree (id)",
+ "index: CREATE UNIQUE INDEX mocha_topic_pkey ON messaging.mocha_topic USING btree (id)",
+ "index: CREATE UNIQUE INDEX mocha_unique_queue ON messaging.mocha_queue USING btree (name) INCLUDE (id)",
+ "index: CREATE UNIQUE INDEX mocha_unique_queue_subscription ON messaging.mocha_queue_subscription USING btree (source_id, destination_id)",
+ "index: CREATE UNIQUE INDEX mocha_unique_topic ON messaging.mocha_topic USING btree (name) INCLUDE (id)",
+ "relation: mocha_consumers r",
+ "relation: mocha_consumers_pkey i",
+ "relation: mocha_consumers_updated_at_ndx i",
+ "relation: mocha_message r",
+ "relation: mocha_message_expiration_scheduled_ndx i",
+ "relation: mocha_message_pkey i",
+ "relation: mocha_message_queue_ndx i",
+ "relation: mocha_message_sent_time_ndx i",
+ "relation: mocha_message_transport_queue_ndx i",
+ "relation: mocha_migrations r",
+ "relation: mocha_migrations_pkey i",
+ "relation: mocha_queue r",
+ "relation: mocha_queue_consumer_id_ndx i",
+ "relation: mocha_queue_pkey i",
+ "relation: mocha_queue_subscription r",
+ "relation: mocha_queue_subscription_dest_ndx i",
+ "relation: mocha_queue_subscription_pkey i",
+ "relation: mocha_queue_subscription_source_ndx i",
+ "relation: mocha_topic r",
+ "relation: mocha_topic_pkey i",
+ "relation: mocha_topology_seq S",
+ "relation: mocha_unique_queue i",
+ "relation: mocha_unique_queue_subscription i",
+ "relation: mocha_unique_topic i"
+]
+```
diff --git a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Helpers/PostgresFixture.cs b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Helpers/PostgresFixture.cs
index 28558bac882..9bfe241f041 100644
--- a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Helpers/PostgresFixture.cs
+++ b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Helpers/PostgresFixture.cs
@@ -22,9 +22,6 @@ public async ValueTask DisposeAsync()
public string ConnectionString => _resource.ConnectionString;
- public Task RunSqlScriptAsync(string sql, string database)
- => _resource.RunSqlScriptAsync(sql, database);
-
///
/// Creates an isolated database for each test to avoid interference.
///
From c259daa1eaa88ffaf7a9e19bf367a1ddd4977dc8 Mon Sep 17 00:00:00 2001
From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com>
Date: Tue, 6 Oct 2026 13:53:19 +0000
Subject: [PATCH 7/8] Cleanup
---
.../PostgresTransportSchemaTests.cs | 112 ++++++++++++------
...ons_When_OnlyInitialMigrationIsRecorded.md | 89 --------------
...ld_CreateResources_When_DatabaseIsEmpty.md | 89 --------------
3 files changed, 79 insertions(+), 211 deletions(-)
delete mode 100644 src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/__snapshots__/PostgresTransportSchemaTests.MigrateAsync_Should_ApplyPendingMigrations_When_OnlyInitialMigrationIsRecorded.md
delete mode 100644 src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/__snapshots__/PostgresTransportSchemaTests.MigrateAsync_Should_CreateResources_When_DatabaseIsEmpty.md
diff --git a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/PostgresTransportSchemaTests.cs b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/PostgresTransportSchemaTests.cs
index 00619819ad4..313c45b70ea 100644
--- a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/PostgresTransportSchemaTests.cs
+++ b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/PostgresTransportSchemaTests.cs
@@ -1,4 +1,3 @@
-using CookieCrumble;
using Microsoft.Extensions.DependencyInjection;
using Mocha.Transport.Postgres.Tests.Helpers;
using Npgsql;
@@ -100,13 +99,23 @@ public async Task MigrateAsync_Should_CreateResources_When_DatabaseIsEmpty(bool
// assert
var migrationHistory = await ReadMigrationHistoryAsync(connection);
+ var tables = await ReadTableNamesAsync(connection);
+ var sequences = await ReadSequenceNamesAsync(connection);
Assert.Equal(
["2026-03-06_AddConsumerManagement", "2026-03-06_AddTransportIndex", "2026-03-06_InitialSchema"],
migrationHistory);
- Snapshot.Create()
- .Add(await ReadSchemaAsync(connection), "Schema")
- .MatchMarkdownSnapshot();
+ Assert.Equal(
+ [
+ "mocha_consumers",
+ "mocha_message",
+ "mocha_migrations",
+ "mocha_queue",
+ "mocha_queue_subscription",
+ "mocha_topic"
+ ],
+ tables);
+ Assert.Equal(["mocha_topology_seq"], sequences);
}
[Theory]
@@ -118,17 +127,17 @@ public async Task MigrateAsync_Should_LeaveResourcesUnchanged_When_RunTwice(bool
await using var database = await fixture.CreateDatabaseAsync();
await using var connection = await OpenConnectionAsync(database.ConnectionString);
await ApplyMigrationsAsync(connection, useMigrationScript);
- var schema = await ReadSchemaAsync(connection);
+ var tables = await ReadTableNamesAsync(connection);
var migrationHistory = await ReadMigrationHistoryAsync(connection);
// act
await ApplyMigrationsAsync(connection, useMigrationScript);
// assert
- var repeatedSchema = await ReadSchemaAsync(connection);
+ var repeatedTables = await ReadTableNamesAsync(connection);
var repeatedMigrationHistory = await ReadMigrationHistoryAsync(connection);
- Assert.Equal(schema, repeatedSchema);
+ Assert.Equal(tables, repeatedTables);
Assert.Equal(migrationHistory, repeatedMigrationHistory);
}
@@ -148,19 +157,38 @@ CREATE TABLE messaging.mocha_migrations (
applied_on timestamptz NOT NULL DEFAULT now());
INSERT INTO messaging.mocha_migrations (migration_id) VALUES ('2026-03-06_InitialSchema');
""");
+ var tables = await ReadTableNamesAsync(connection);
// act
await ApplyMigrationsAsync(connection, useMigrationScript);
// assert
var migrationHistory = await ReadMigrationHistoryAsync(connection);
+ var migratedTables = await ReadTableNamesAsync(connection);
+ var messageIndexes = await ReadMessageIndexNamesAsync(connection);
+ var queueColumns = await ReadQueueColumnNamesAsync(connection);
Assert.Equal(
["2026-03-06_AddConsumerManagement", "2026-03-06_AddTransportIndex", "2026-03-06_InitialSchema"],
migrationHistory);
- Snapshot.Create()
- .Add(await ReadSchemaAsync(connection), "Schema")
- .MatchMarkdownSnapshot();
+ Assert.Equal(["mocha_consumers"], migratedTables.Except(tables));
+ Assert.Equal(
+ [
+ "mocha_message_expiration_scheduled_ndx",
+ "mocha_message_pkey",
+ "mocha_message_queue_ndx",
+ "mocha_message_sent_time_ndx",
+ "mocha_message_transport_queue_ndx"
+ ],
+ messageIndexes);
+ Assert.Equal(
+ [
+ "id",
+ "updated",
+ "name",
+ "consumer_id"
+ ],
+ queueColumns);
}
[Fact]
@@ -226,21 +254,21 @@ public async Task MigrateAsync_Should_RollBack_When_MigrationFails()
// arrange
await using var database = await fixture.CreateDatabaseAsync();
await using var connection = await OpenConnectionAsync(database.ConnectionString);
+ // The conflicting view deliberately makes index creation fail after earlier resources have been created.
await ExecuteNonQueryAsync(connection,
"""
CREATE SCHEMA messaging;
CREATE VIEW messaging.mocha_message AS SELECT 1 AS sentinel;
""");
- var schema = await ReadSchemaAsync(connection);
// act
await Assert.ThrowsAsync(
() => PostgresTransportSchema.MigrateAsync(connection, _schemaOptions, CancellationToken));
// assert
- var schemaAfterFailure = await ReadSchemaAsync(connection);
+ var relations = await ReadRelationNamesAsync(connection);
- Assert.Equal(schema, schemaAfterFailure);
+ Assert.Equal(["mocha_message"], relations);
}
private static async Task OpenConnectionAsync(string connectionString)
@@ -280,25 +308,43 @@ private static Task ReadMigrationHistoryAsync(NpgsqlConnection connect
=> ExecuteScalarAsync(connection,
"SELECT array_agg(migration_id ORDER BY migration_id) FROM messaging.mocha_migrations;");
- private static Task ReadSchemaAsync(NpgsqlConnection connection)
- {
- const string sql =
+ private static Task ReadTableNamesAsync(NpgsqlConnection connection)
+ => ExecuteScalarAsync(connection,
"""
- SELECT array_agg(description ORDER BY description)
- FROM (
- SELECT 'relation: ' || relname || ' ' || relkind::text AS description
- FROM pg_class WHERE relnamespace = 'messaging'::regnamespace
- UNION ALL
- SELECT 'column: ' || table_name || '.' || column_name || ' ' || data_type || ' ' || is_nullable
- FROM information_schema.columns WHERE table_schema = 'messaging'
- UNION ALL
- SELECT 'constraint: ' || conname || ' ' || pg_get_constraintdef(oid)
- FROM pg_constraint WHERE connamespace = 'messaging'::regnamespace
- UNION ALL
- SELECT 'index: ' || indexdef FROM pg_indexes WHERE schemaname = 'messaging'
- ) state;
- """;
-
- return ExecuteScalarAsync(connection, sql);
- }
+ SELECT array_agg(table_name ORDER BY table_name)
+ FROM information_schema.tables
+ WHERE table_schema = 'messaging' AND table_type = 'BASE TABLE';
+ """);
+
+ private static Task ReadSequenceNamesAsync(NpgsqlConnection connection)
+ => ExecuteScalarAsync(connection,
+ """
+ SELECT array_agg(sequence_name ORDER BY sequence_name)
+ FROM information_schema.sequences
+ WHERE sequence_schema = 'messaging';
+ """);
+
+ private static Task ReadMessageIndexNamesAsync(NpgsqlConnection connection)
+ => ExecuteScalarAsync(connection,
+ """
+ SELECT array_agg(indexname ORDER BY indexname)
+ FROM pg_indexes
+ WHERE schemaname = 'messaging' AND tablename = 'mocha_message';
+ """);
+
+ private static Task ReadQueueColumnNamesAsync(NpgsqlConnection connection)
+ => ExecuteScalarAsync(connection,
+ """
+ SELECT array_agg(column_name ORDER BY ordinal_position)
+ FROM information_schema.columns
+ WHERE table_schema = 'messaging' AND table_name = 'mocha_queue';
+ """);
+
+ private static Task ReadRelationNamesAsync(NpgsqlConnection connection)
+ => ExecuteScalarAsync(connection,
+ """
+ SELECT array_agg(relname ORDER BY relname)
+ FROM pg_class
+ WHERE relnamespace = 'messaging'::regnamespace;
+ """);
}
diff --git a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/__snapshots__/PostgresTransportSchemaTests.MigrateAsync_Should_ApplyPendingMigrations_When_OnlyInitialMigrationIsRecorded.md b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/__snapshots__/PostgresTransportSchemaTests.MigrateAsync_Should_ApplyPendingMigrations_When_OnlyInitialMigrationIsRecorded.md
deleted file mode 100644
index a0ac7ea13cf..00000000000
--- a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/__snapshots__/PostgresTransportSchemaTests.MigrateAsync_Should_ApplyPendingMigrations_When_OnlyInitialMigrationIsRecorded.md
+++ /dev/null
@@ -1,89 +0,0 @@
-# MigrateAsync_Should_ApplyPendingMigrations_When_OnlyInitialMigrationIsRecorded
-
-```json
-[
- "column: mocha_consumers.created_at timestamp with time zone NO",
- "column: mocha_consumers.id uuid NO",
- "column: mocha_consumers.service_name text NO",
- "column: mocha_consumers.updated_at timestamp with time zone NO",
- "column: mocha_message.body bytea NO",
- "column: mocha_message.consumer_id uuid YES",
- "column: mocha_message.delivery_count integer NO",
- "column: mocha_message.error_reason jsonb YES",
- "column: mocha_message.expiration_time timestamp with time zone YES",
- "column: mocha_message.headers jsonb YES",
- "column: mocha_message.last_delivered timestamp with time zone YES",
- "column: mocha_message.max_delivery_count integer NO",
- "column: mocha_message.queue_id bigint NO",
- "column: mocha_message.scheduled_time timestamp with time zone YES",
- "column: mocha_message.sent_time timestamp with time zone NO",
- "column: mocha_message.transport_message_id uuid NO",
- "column: mocha_migrations.applied_on timestamp with time zone NO",
- "column: mocha_migrations.migration_id text NO",
- "column: mocha_queue.consumer_id uuid YES",
- "column: mocha_queue.id bigint NO",
- "column: mocha_queue.name text NO",
- "column: mocha_queue.updated timestamp with time zone NO",
- "column: mocha_queue_subscription.destination_id bigint NO",
- "column: mocha_queue_subscription.id bigint NO",
- "column: mocha_queue_subscription.source_id bigint NO",
- "column: mocha_queue_subscription.updated timestamp with time zone NO",
- "column: mocha_topic.id bigint NO",
- "column: mocha_topic.name text NO",
- "column: mocha_topic.updated timestamp with time zone NO",
- "constraint: mocha_consumers_pkey PRIMARY KEY (id)",
- "constraint: mocha_message_pkey PRIMARY KEY (transport_message_id)",
- "constraint: mocha_message_queue_id_fkey FOREIGN KEY (queue_id) REFERENCES messaging.mocha_queue(id) ON DELETE CASCADE",
- "constraint: mocha_migrations_pkey PRIMARY KEY (migration_id)",
- "constraint: mocha_queue_consumer_id_fkey FOREIGN KEY (consumer_id) REFERENCES messaging.mocha_consumers(id) ON DELETE CASCADE",
- "constraint: mocha_queue_pkey PRIMARY KEY (id)",
- "constraint: mocha_queue_subscription_destination_id_fkey FOREIGN KEY (destination_id) REFERENCES messaging.mocha_queue(id) ON DELETE CASCADE",
- "constraint: mocha_queue_subscription_pkey PRIMARY KEY (id)",
- "constraint: mocha_queue_subscription_source_id_fkey FOREIGN KEY (source_id) REFERENCES messaging.mocha_topic(id) ON DELETE CASCADE",
- "constraint: mocha_topic_pkey PRIMARY KEY (id)",
- "constraint: mocha_unique_queue UNIQUE (name) INCLUDE (id)",
- "constraint: mocha_unique_queue_subscription UNIQUE (source_id, destination_id)",
- "constraint: mocha_unique_topic UNIQUE (name) INCLUDE (id)",
- "index: CREATE INDEX mocha_consumers_updated_at_ndx ON messaging.mocha_consumers USING btree (updated_at)",
- "index: CREATE INDEX mocha_message_expiration_scheduled_ndx ON messaging.mocha_message USING btree (expiration_time, scheduled_time)",
- "index: CREATE INDEX mocha_message_queue_ndx ON messaging.mocha_message USING btree (queue_id) INCLUDE (transport_message_id)",
- "index: CREATE INDEX mocha_message_sent_time_ndx ON messaging.mocha_message USING btree (sent_time)",
- "index: CREATE INDEX mocha_message_transport_queue_ndx ON messaging.mocha_message USING btree (transport_message_id, queue_id)",
- "index: CREATE INDEX mocha_queue_consumer_id_ndx ON messaging.mocha_queue USING btree (consumer_id)",
- "index: CREATE INDEX mocha_queue_subscription_dest_ndx ON messaging.mocha_queue_subscription USING btree (destination_id) INCLUDE (id, source_id)",
- "index: CREATE INDEX mocha_queue_subscription_source_ndx ON messaging.mocha_queue_subscription USING btree (source_id) INCLUDE (id, destination_id)",
- "index: CREATE UNIQUE INDEX mocha_consumers_pkey ON messaging.mocha_consumers USING btree (id)",
- "index: CREATE UNIQUE INDEX mocha_message_pkey ON messaging.mocha_message USING btree (transport_message_id)",
- "index: CREATE UNIQUE INDEX mocha_migrations_pkey ON messaging.mocha_migrations USING btree (migration_id)",
- "index: CREATE UNIQUE INDEX mocha_queue_pkey ON messaging.mocha_queue USING btree (id)",
- "index: CREATE UNIQUE INDEX mocha_queue_subscription_pkey ON messaging.mocha_queue_subscription USING btree (id)",
- "index: CREATE UNIQUE INDEX mocha_topic_pkey ON messaging.mocha_topic USING btree (id)",
- "index: CREATE UNIQUE INDEX mocha_unique_queue ON messaging.mocha_queue USING btree (name) INCLUDE (id)",
- "index: CREATE UNIQUE INDEX mocha_unique_queue_subscription ON messaging.mocha_queue_subscription USING btree (source_id, destination_id)",
- "index: CREATE UNIQUE INDEX mocha_unique_topic ON messaging.mocha_topic USING btree (name) INCLUDE (id)",
- "relation: mocha_consumers r",
- "relation: mocha_consumers_pkey i",
- "relation: mocha_consumers_updated_at_ndx i",
- "relation: mocha_message r",
- "relation: mocha_message_expiration_scheduled_ndx i",
- "relation: mocha_message_pkey i",
- "relation: mocha_message_queue_ndx i",
- "relation: mocha_message_sent_time_ndx i",
- "relation: mocha_message_transport_queue_ndx i",
- "relation: mocha_migrations r",
- "relation: mocha_migrations_pkey i",
- "relation: mocha_queue r",
- "relation: mocha_queue_consumer_id_ndx i",
- "relation: mocha_queue_pkey i",
- "relation: mocha_queue_subscription r",
- "relation: mocha_queue_subscription_dest_ndx i",
- "relation: mocha_queue_subscription_pkey i",
- "relation: mocha_queue_subscription_source_ndx i",
- "relation: mocha_topic r",
- "relation: mocha_topic_pkey i",
- "relation: mocha_topology_seq S",
- "relation: mocha_unique_queue i",
- "relation: mocha_unique_queue_subscription i",
- "relation: mocha_unique_topic i"
-]
-```
diff --git a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/__snapshots__/PostgresTransportSchemaTests.MigrateAsync_Should_CreateResources_When_DatabaseIsEmpty.md b/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/__snapshots__/PostgresTransportSchemaTests.MigrateAsync_Should_CreateResources_When_DatabaseIsEmpty.md
deleted file mode 100644
index 926c5452ce5..00000000000
--- a/src/Mocha/test/Mocha.Transport.Postgres.Tests/Connection/__snapshots__/PostgresTransportSchemaTests.MigrateAsync_Should_CreateResources_When_DatabaseIsEmpty.md
+++ /dev/null
@@ -1,89 +0,0 @@
-# MigrateAsync_Should_CreateResources_When_DatabaseIsEmpty
-
-```json
-[
- "column: mocha_consumers.created_at timestamp with time zone NO",
- "column: mocha_consumers.id uuid NO",
- "column: mocha_consumers.service_name text NO",
- "column: mocha_consumers.updated_at timestamp with time zone NO",
- "column: mocha_message.body bytea NO",
- "column: mocha_message.consumer_id uuid YES",
- "column: mocha_message.delivery_count integer NO",
- "column: mocha_message.error_reason jsonb YES",
- "column: mocha_message.expiration_time timestamp with time zone YES",
- "column: mocha_message.headers jsonb YES",
- "column: mocha_message.last_delivered timestamp with time zone YES",
- "column: mocha_message.max_delivery_count integer NO",
- "column: mocha_message.queue_id bigint NO",
- "column: mocha_message.scheduled_time timestamp with time zone YES",
- "column: mocha_message.sent_time timestamp with time zone NO",
- "column: mocha_message.transport_message_id uuid NO",
- "column: mocha_migrations.applied_on timestamp with time zone NO",
- "column: mocha_migrations.migration_id text NO",
- "column: mocha_queue.consumer_id uuid YES",
- "column: mocha_queue.id bigint NO",
- "column: mocha_queue.name text NO",
- "column: mocha_queue.updated timestamp with time zone NO",
- "column: mocha_queue_subscription.destination_id bigint NO",
- "column: mocha_queue_subscription.id bigint NO",
- "column: mocha_queue_subscription.source_id bigint NO",
- "column: mocha_queue_subscription.updated timestamp with time zone NO",
- "column: mocha_topic.id bigint NO",
- "column: mocha_topic.name text NO",
- "column: mocha_topic.updated timestamp with time zone NO",
- "constraint: mocha_consumers_pkey PRIMARY KEY (id)",
- "constraint: mocha_message_pkey PRIMARY KEY (transport_message_id)",
- "constraint: mocha_message_queue_id_fkey FOREIGN KEY (queue_id) REFERENCES messaging.mocha_queue(id) ON DELETE CASCADE",
- "constraint: mocha_migrations_pkey PRIMARY KEY (migration_id)",
- "constraint: mocha_queue_consumer_id_fkey FOREIGN KEY (consumer_id) REFERENCES messaging.mocha_consumers(id) ON DELETE CASCADE",
- "constraint: mocha_queue_pkey PRIMARY KEY (id)",
- "constraint: mocha_queue_subscription_destination_id_fkey FOREIGN KEY (destination_id) REFERENCES messaging.mocha_queue(id) ON DELETE CASCADE",
- "constraint: mocha_queue_subscription_pkey PRIMARY KEY (id)",
- "constraint: mocha_queue_subscription_source_id_fkey FOREIGN KEY (source_id) REFERENCES messaging.mocha_topic(id) ON DELETE CASCADE",
- "constraint: mocha_topic_pkey PRIMARY KEY (id)",
- "constraint: mocha_unique_queue UNIQUE (name) INCLUDE (id)",
- "constraint: mocha_unique_queue_subscription UNIQUE (source_id, destination_id)",
- "constraint: mocha_unique_topic UNIQUE (name) INCLUDE (id)",
- "index: CREATE INDEX mocha_consumers_updated_at_ndx ON messaging.mocha_consumers USING btree (updated_at)",
- "index: CREATE INDEX mocha_message_expiration_scheduled_ndx ON messaging.mocha_message USING btree (expiration_time, scheduled_time)",
- "index: CREATE INDEX mocha_message_queue_ndx ON messaging.mocha_message USING btree (queue_id) INCLUDE (transport_message_id)",
- "index: CREATE INDEX mocha_message_sent_time_ndx ON messaging.mocha_message USING btree (sent_time)",
- "index: CREATE INDEX mocha_message_transport_queue_ndx ON messaging.mocha_message USING btree (transport_message_id, queue_id)",
- "index: CREATE INDEX mocha_queue_consumer_id_ndx ON messaging.mocha_queue USING btree (consumer_id)",
- "index: CREATE INDEX mocha_queue_subscription_dest_ndx ON messaging.mocha_queue_subscription USING btree (destination_id) INCLUDE (id, source_id)",
- "index: CREATE INDEX mocha_queue_subscription_source_ndx ON messaging.mocha_queue_subscription USING btree (source_id) INCLUDE (id, destination_id)",
- "index: CREATE UNIQUE INDEX mocha_consumers_pkey ON messaging.mocha_consumers USING btree (id)",
- "index: CREATE UNIQUE INDEX mocha_message_pkey ON messaging.mocha_message USING btree (transport_message_id)",
- "index: CREATE UNIQUE INDEX mocha_migrations_pkey ON messaging.mocha_migrations USING btree (migration_id)",
- "index: CREATE UNIQUE INDEX mocha_queue_pkey ON messaging.mocha_queue USING btree (id)",
- "index: CREATE UNIQUE INDEX mocha_queue_subscription_pkey ON messaging.mocha_queue_subscription USING btree (id)",
- "index: CREATE UNIQUE INDEX mocha_topic_pkey ON messaging.mocha_topic USING btree (id)",
- "index: CREATE UNIQUE INDEX mocha_unique_queue ON messaging.mocha_queue USING btree (name) INCLUDE (id)",
- "index: CREATE UNIQUE INDEX mocha_unique_queue_subscription ON messaging.mocha_queue_subscription USING btree (source_id, destination_id)",
- "index: CREATE UNIQUE INDEX mocha_unique_topic ON messaging.mocha_topic USING btree (name) INCLUDE (id)",
- "relation: mocha_consumers r",
- "relation: mocha_consumers_pkey i",
- "relation: mocha_consumers_updated_at_ndx i",
- "relation: mocha_message r",
- "relation: mocha_message_expiration_scheduled_ndx i",
- "relation: mocha_message_pkey i",
- "relation: mocha_message_queue_ndx i",
- "relation: mocha_message_sent_time_ndx i",
- "relation: mocha_message_transport_queue_ndx i",
- "relation: mocha_migrations r",
- "relation: mocha_migrations_pkey i",
- "relation: mocha_queue r",
- "relation: mocha_queue_consumer_id_ndx i",
- "relation: mocha_queue_pkey i",
- "relation: mocha_queue_subscription r",
- "relation: mocha_queue_subscription_dest_ndx i",
- "relation: mocha_queue_subscription_pkey i",
- "relation: mocha_queue_subscription_source_ndx i",
- "relation: mocha_topic r",
- "relation: mocha_topic_pkey i",
- "relation: mocha_topology_seq S",
- "relation: mocha_unique_queue i",
- "relation: mocha_unique_queue_subscription i",
- "relation: mocha_unique_topic i"
-]
-```
From f75e503fc1584ffd6c35f2aabf72e32e5834058a Mon Sep 17 00:00:00 2001
From: Pascal Senn
Date: Tue, 6 Oct 2026 20:29:43 +0200
Subject: [PATCH 8/8] cleanup
---
.../Descriptors/PostgresQueueDescriptor.cs | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/src/Mocha/src/Mocha.Transport.Postgres/Descriptors/PostgresQueueDescriptor.cs b/src/Mocha/src/Mocha.Transport.Postgres/Descriptors/PostgresQueueDescriptor.cs
index 3f651fdcc90..7495893f83f 100644
--- a/src/Mocha/src/Mocha.Transport.Postgres/Descriptors/PostgresQueueDescriptor.cs
+++ b/src/Mocha/src/Mocha.Transport.Postgres/Descriptors/PostgresQueueDescriptor.cs
@@ -9,8 +9,7 @@ internal sealed class PostgresQueueDescriptor
: MessagingDescriptorBase
, IPostgresQueueDescriptor
{
- private PostgresQueueDescriptor(IMessagingConfigurationContext context, string name)
- : base(context)
+ private PostgresQueueDescriptor(IMessagingConfigurationContext context, string name) : base(context)
{
Configuration = new PostgresQueueDescriptorConfiguration(name);
}
@@ -125,7 +124,7 @@ public IPostgresQueueDescriptor UseReceive(
{
if (before is not null && after is not null)
{
- throw Mocha.ThrowHelper.BeforeAndAfterConflict();
+ throw ThrowHelper.BeforeAndAfterConflict();
}
if (before is null && after is null)