diff --git a/src/CoreTests/Bugs/Bug_4614_revision_column_int_for_IRevisioned.cs b/src/CoreTests/Bugs/Bug_4614_revision_column_int_for_IRevisioned.cs
new file mode 100644
index 0000000000..9c1756488c
--- /dev/null
+++ b/src/CoreTests/Bugs/Bug_4614_revision_column_int_for_IRevisioned.cs
@@ -0,0 +1,278 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using JasperFx;
+using JasperFx.Events;
+using JasperFx.Events.Projections;
+using Marten;
+using Marten.Events.Aggregation;
+using Marten.Events.Projections;
+using Marten.Metadata;
+using Marten.Storage.Metadata;
+using Marten.Testing.Harness;
+using Npgsql;
+using Shouldly;
+using Weasel.Core;
+using Weasel.Postgresql;
+using Xunit;
+
+namespace CoreTests.Bugs;
+
+///
+/// #4614 — Marten 8 → 9 upgrade was migrating the mt_version column on
+/// SingleStreamProjection aggregate document tables from integer to
+/// bigint. That widening was a side-effect of RevisionColumn
+/// becoming MetadataColumn<long> in #3733; the .NET-side
+/// IRevisioned.Version was reverted to int in #4533, but the column
+/// width was not. The fix splits the variant in two: IRevisioned-backed
+/// documents (the V8 default; what SingleStreamProjection aggregates use)
+/// get integer; ILongVersioned-backed documents (MultiStreamProjection's
+/// shape, where Version is a global event-sequence number) keep bigint.
+///
+/// Migration is non-destructive in both directions:
+///
+/// - V8 schema (integer) + IRevisioned (desired integer) — no migration.
+/// - V8 schema (integer) + ILongVersioned (desired bigint) — widen to bigint.
+/// - 9.x deployment already migrated to bigint + IRevisioned (desired integer)
+/// — tolerated, no force-narrow (a USING cast would risk silent data loss).
+///
+///
+public class Bug_4614_revision_column_int_for_IRevisioned: OneOffConfigurationsContext
+{
+ // ---- Fresh-creation tests: new tables match the V8 column width ----
+
+ [Fact]
+ public async Task fresh_table_for_IRevisioned_uses_integer_column()
+ {
+ StoreOptions(opts => opts.Schema.For());
+ await theStore.Storage.ApplyAllConfiguredChangesToDatabaseAsync();
+
+ var actualType = await readVersionColumnType(typeof(RevisionedDoc));
+ actualType.ShouldBe("integer");
+ }
+
+ [Fact]
+ public async Task fresh_table_for_ILongVersioned_uses_bigint_column()
+ {
+ StoreOptions(opts => opts.Schema.For());
+ await theStore.Storage.ApplyAllConfiguredChangesToDatabaseAsync();
+
+ var actualType = await readVersionColumnType(typeof(LongVersionedDoc));
+ actualType.ShouldBe("bigint");
+ }
+
+ // ---- Migration tolerance — the regression's user-visible surface ----
+
+ [Fact]
+ public async Task V8_integer_column_for_IRevisioned_is_not_migrated_to_bigint()
+ {
+ // Stage 1: stand up the schema, then deliberately seed the V8 shape — a real V8
+ // deployment's table for an IRevisioned document had `mt_version integer`. On the
+ // current code (post-fix), the desired type is integer too, so apply should detect
+ // no work to do and leave the column alone — i.e. NO `ALTER COLUMN … TYPE bigint`
+ // gets emitted on the V8 → V9 upgrade.
+ StoreOptions(opts => opts.Schema.For());
+ await theStore.Storage.ApplyAllConfiguredChangesToDatabaseAsync();
+
+ await using (var conn = new NpgsqlConnection(ConnectionSource.ConnectionString))
+ {
+ await conn.OpenAsync();
+ await conn.CreateCommand(
+ $"alter table {SchemaName}.mt_doc_revisioneddoc alter column mt_version type integer using mt_version::integer")
+ .ExecuteNonQueryAsync();
+ }
+
+ // Second apply must be a no-op for this column — the desired-vs-actual diff sees
+ // "integer integer", not "bigint integer", and emits nothing.
+ await theStore.Storage.ApplyAllConfiguredChangesToDatabaseAsync();
+
+ (await readVersionColumnType(typeof(RevisionedDoc))).ShouldBe("integer");
+ }
+
+ [Fact]
+ public async Task V8_integer_column_for_ILongVersioned_still_widens_to_bigint()
+ {
+ // The legitimate V8 → V9 widening path is preserved for ILongVersioned-typed
+ // documents (the only docs that legitimately need a bigint column going forward).
+ StoreOptions(opts => opts.Schema.For());
+ await theStore.Storage.ApplyAllConfiguredChangesToDatabaseAsync();
+
+ await using (var conn = new NpgsqlConnection(ConnectionSource.ConnectionString))
+ {
+ await conn.OpenAsync();
+ await conn.CreateCommand(
+ $"alter table {SchemaName}.mt_doc_longversioneddoc alter column mt_version type integer using mt_version::integer")
+ .ExecuteNonQueryAsync();
+ }
+
+ await theStore.Storage.ApplyAllConfiguredChangesToDatabaseAsync();
+
+ (await readVersionColumnType(typeof(LongVersionedDoc))).ShouldBe("bigint");
+ }
+
+ [Fact]
+ public async Task existing_9x_bigint_column_for_IRevisioned_is_tolerated_not_narrowed()
+ {
+ // The reverse-direction safety: a deployment that already migrated to V9-with-bigint
+ // (before this fix) MUST NOT get force-narrowed to integer on the next apply — a
+ // `USING mt_version::integer` cast would silently truncate any out-of-range value.
+ // The diff treats bigint-actual + integer-desired as compatible (no SQL emitted).
+ StoreOptions(opts => opts.Schema.For());
+ await theStore.Storage.ApplyAllConfiguredChangesToDatabaseAsync();
+
+ await using (var conn = new NpgsqlConnection(ConnectionSource.ConnectionString))
+ {
+ await conn.OpenAsync();
+ await conn.CreateCommand(
+ $"alter table {SchemaName}.mt_doc_revisioneddoc alter column mt_version type bigint")
+ .ExecuteNonQueryAsync();
+ }
+
+ await theStore.Storage.ApplyAllConfiguredChangesToDatabaseAsync();
+
+ (await readVersionColumnType(typeof(RevisionedDoc))).ShouldBe("bigint");
+ }
+
+ // ---- CRUD round-trip on both shapes ----
+
+ [Fact]
+ public async Task IRevisioned_round_trip_insert_update_read()
+ {
+ StoreOptions(opts => opts.Schema.For());
+
+ var doc = new RevisionedDoc { Id = Guid.NewGuid(), Name = "alpha" };
+
+ await using (var session = theStore.LightweightSession())
+ {
+ session.UpdateRevision(doc, 1);
+ await session.SaveChangesAsync();
+ }
+
+ await using (var session = theStore.LightweightSession())
+ {
+ doc.Name = "beta";
+ session.UpdateRevision(doc, 2);
+ await session.SaveChangesAsync();
+ }
+
+ await using (var query = theStore.QuerySession())
+ {
+ var loaded = await query.LoadAsync(doc.Id);
+ loaded.ShouldNotBeNull();
+ loaded.Name.ShouldBe("beta");
+ loaded.Version.ShouldBe(2);
+ }
+ }
+
+ [Fact]
+ public async Task ILongVersioned_round_trip_insert_update_read()
+ {
+ StoreOptions(opts => opts.Schema.For());
+
+ var doc = new LongVersionedDoc { Id = Guid.NewGuid(), Name = "alpha" };
+
+ await using (var session = theStore.LightweightSession())
+ {
+ session.UpdateRevision(doc, 1);
+ await session.SaveChangesAsync();
+ }
+
+ await using (var session = theStore.LightweightSession())
+ {
+ doc.Name = "beta";
+ session.UpdateRevision(doc, 2);
+ await session.SaveChangesAsync();
+ }
+
+ await using (var query = theStore.QuerySession())
+ {
+ var loaded = await query.LoadAsync(doc.Id);
+ loaded.ShouldNotBeNull();
+ loaded.Name.ShouldBe("beta");
+ loaded.Version.ShouldBe(2L);
+ }
+ }
+
+ // ---- SingleStreamProjection registration auto-picks the right variant ----
+
+ [Fact]
+ public async Task SingleStreamProjection_of_IRevisioned_document_gets_integer_column()
+ {
+ StoreOptions(opts =>
+ {
+ opts.Events.AddEventType();
+ opts.Projections.Add(ProjectionLifecycle.Inline);
+ });
+
+ await theStore.Storage.ApplyAllConfiguredChangesToDatabaseAsync();
+
+ (await readVersionColumnType(typeof(NamedAggregate))).ShouldBe("integer");
+
+ // Round-trip through the actual projection path to prove the integer column
+ // works end-to-end: append event → inline projection writes the doc → read back.
+ var streamId = Guid.NewGuid();
+ await using (var session = theStore.LightweightSession())
+ {
+ session.Events.StartStream(streamId, new NameChanged("first"));
+ await session.SaveChangesAsync();
+ }
+
+ await using (var session = theStore.LightweightSession())
+ {
+ session.Events.Append(streamId, new NameChanged("second"));
+ await session.SaveChangesAsync();
+ }
+
+ await using (var query = theStore.QuerySession())
+ {
+ var agg = await query.LoadAsync(streamId);
+ agg.ShouldNotBeNull();
+ agg.Name.ShouldBe("second");
+ agg.Version.ShouldBe(2);
+ }
+ }
+
+ // ---- helper ----
+
+ private async Task readVersionColumnType(Type docType)
+ {
+ await using var conn = new NpgsqlConnection(ConnectionSource.ConnectionString);
+ await conn.OpenAsync();
+ var tableName = "mt_doc_" + docType.Name.ToLowerInvariant();
+ var dataType = (string?)await conn.CreateCommand(
+ "select data_type from information_schema.columns where table_schema = :s and table_name = :t and column_name = 'mt_version'")
+ .With("s", SchemaName)
+ .With("t", tableName)
+ .ExecuteScalarAsync();
+ return dataType ?? throw new InvalidOperationException(
+ $"mt_version column not found on {SchemaName}.{tableName}");
+ }
+}
+
+public class RevisionedDoc: IRevisioned
+{
+ public Guid Id { get; set; }
+ public string Name { get; set; } = string.Empty;
+ public int Version { get; set; }
+}
+
+public class LongVersionedDoc: ILongVersioned
+{
+ public Guid Id { get; set; }
+ public string Name { get; set; } = string.Empty;
+ public long Version { get; set; }
+}
+
+public record NameChanged(string Name);
+
+public class NamedAggregate: IRevisioned
+{
+ public Guid Id { get; set; }
+ public string Name { get; set; } = string.Empty;
+ public int Version { get; set; }
+}
+
+public partial class NamedAggregateProjection: SingleStreamProjection
+{
+ public void Apply(NameChanged @event, NamedAggregate agg) => agg.Name = @event.Name;
+}
diff --git a/src/Marten/Internal/ClosedShape/ClosedShapeInsertOperation.cs b/src/Marten/Internal/ClosedShape/ClosedShapeInsertOperation.cs
index e7b7efd8f7..58574c2365 100644
--- a/src/Marten/Internal/ClosedShape/ClosedShapeInsertOperation.cs
+++ b/src/Marten/Internal/ClosedShape/ClosedShapeInsertOperation.cs
@@ -150,8 +150,13 @@ private int BindBinder(NpgsqlParameter[] parameters, int slot, IDocumentMetadata
// THEN COALESCE((select version from .mt_streams where id = ?), 1)
// ELSE ? END (3 slots: ?=0 check, subquery id, explicit revision)
// UseVersionFromMatchingStream + conjoined: same with extra ? for tenant_id (4 slots)
- parameters[slot].Value = Revision;
- parameters[slot].NpgsqlDbType = NpgsqlDbType.Bigint;
+ // #4614: parameter type follows the column width (integer vs bigint).
+ var revisionDbType = _descriptor.RevisionBinder.ColumnDbType;
+ var revisionValue = revisionDbType == NpgsqlDbType.Integer
+ ? (object)checked((int)Revision)
+ : Revision;
+ parameters[slot].Value = revisionValue;
+ parameters[slot].NpgsqlDbType = revisionDbType;
slot++;
if (_descriptor.UseVersionFromMatchingStream)
@@ -168,8 +173,8 @@ private int BindBinder(NpgsqlParameter[] parameters, int slot, IDocumentMetadata
}
}
- parameters[slot].Value = Revision;
- parameters[slot].NpgsqlDbType = NpgsqlDbType.Bigint;
+ parameters[slot].Value = revisionValue;
+ parameters[slot].NpgsqlDbType = revisionDbType;
return slot + 1;
}
diff --git a/src/Marten/Internal/ClosedShape/ClosedShapeUpdateOperation.cs b/src/Marten/Internal/ClosedShape/ClosedShapeUpdateOperation.cs
index a7f8fab4cd..421be4db41 100644
--- a/src/Marten/Internal/ClosedShape/ClosedShapeUpdateOperation.cs
+++ b/src/Marten/Internal/ClosedShape/ClosedShapeUpdateOperation.cs
@@ -134,12 +134,16 @@ public void ConfigureCommand(ICommandBuilder builder, IMartenSession session)
}
else if (_descriptor.ConcurrencyMode == ConcurrencyMode.Numeric)
{
- // WHERE (? = 0 or {table}.mt_version < ?) — bind raw
- // Revision to both slots.
- parameters[slot].Value = Revision;
- parameters[slot].NpgsqlDbType = NpgsqlDbType.Bigint;
- parameters[slot + 1].Value = Revision;
- parameters[slot + 1].NpgsqlDbType = NpgsqlDbType.Bigint;
+ // WHERE (? = 0 or {table}.mt_version < ?) — bind raw Revision to both slots.
+ // #4614: parameter type tracks the column width (integer/bigint).
+ var revisionDbType = _descriptor.RevisionBinder!.ColumnDbType;
+ var revisionValue = revisionDbType == NpgsqlDbType.Integer
+ ? (object)checked((int)Revision)
+ : Revision;
+ parameters[slot].Value = revisionValue;
+ parameters[slot].NpgsqlDbType = revisionDbType;
+ parameters[slot + 1].Value = revisionValue;
+ parameters[slot + 1].NpgsqlDbType = revisionDbType;
}
}
@@ -177,10 +181,15 @@ private int BindBinder(NpgsqlParameter[] parameters, int slot, IDocumentMetadata
ReferenceEquals(binder, _descriptor.RevisionBinder))
{
// SET mt_version = CASE WHEN ? = 0 THEN current+1 ELSE ? END
- parameters[slot].Value = Revision;
- parameters[slot].NpgsqlDbType = NpgsqlDbType.Bigint;
- parameters[slot + 1].Value = Revision;
- parameters[slot + 1].NpgsqlDbType = NpgsqlDbType.Bigint;
+ // #4614: parameter type tracks the column width (integer/bigint).
+ var revisionDbType = _descriptor.RevisionBinder.ColumnDbType;
+ var revisionValue = revisionDbType == NpgsqlDbType.Integer
+ ? (object)checked((int)Revision)
+ : Revision;
+ parameters[slot].Value = revisionValue;
+ parameters[slot].NpgsqlDbType = revisionDbType;
+ parameters[slot + 1].Value = revisionValue;
+ parameters[slot + 1].NpgsqlDbType = revisionDbType;
return slot + 2;
}
diff --git a/src/Marten/Internal/ClosedShape/ClosedShapeUpsertOperation.cs b/src/Marten/Internal/ClosedShape/ClosedShapeUpsertOperation.cs
index 16290b9fed..f8c857bf6e 100644
--- a/src/Marten/Internal/ClosedShape/ClosedShapeUpsertOperation.cs
+++ b/src/Marten/Internal/ClosedShape/ClosedShapeUpsertOperation.cs
@@ -188,8 +188,14 @@ private int BindBinder(NpgsqlParameter[] parameters, int slot, IDocumentMetadata
// UseVersionFromMatchingStream + conjoined: same with extra ? for tenant_id (4 slots)
// The ON CONFLICT SET / WHERE branches always reference {table}.id
// directly so they keep their 4 revision slots downstream.
- parameters[slot].Value = Revision;
- parameters[slot].NpgsqlDbType = NpgsqlDbType.Bigint;
+ // #4614: the parameter type follows the column width (integer for IRevisioned,
+ // bigint for ILongVersioned) so the CASE expression's branch types align.
+ var revisionDbType = _descriptor.RevisionBinder.ColumnDbType;
+ var revisionValue = revisionDbType == NpgsqlDbType.Integer
+ ? (object)checked((int)Revision)
+ : Revision;
+ parameters[slot].Value = revisionValue;
+ parameters[slot].NpgsqlDbType = revisionDbType;
slot++;
if (_descriptor.UseVersionFromMatchingStream)
@@ -206,8 +212,8 @@ private int BindBinder(NpgsqlParameter[] parameters, int slot, IDocumentMetadata
}
}
- parameters[slot].Value = Revision;
- parameters[slot].NpgsqlDbType = NpgsqlDbType.Bigint;
+ parameters[slot].Value = revisionValue;
+ parameters[slot].NpgsqlDbType = revisionDbType;
return slot + 1;
}
diff --git a/src/Marten/Internal/ClosedShape/DocumentRevisionBinder.cs b/src/Marten/Internal/ClosedShape/DocumentRevisionBinder.cs
index 4752975570..633a20c57a 100644
--- a/src/Marten/Internal/ClosedShape/DocumentRevisionBinder.cs
+++ b/src/Marten/Internal/ClosedShape/DocumentRevisionBinder.cs
@@ -30,16 +30,28 @@ internal sealed class DocumentRevisionBinder: IDocumentMetadataBinder? _setter;
+ private readonly NpgsqlDbType _columnDbType;
public DocumentRevisionBinder(MemberInfo? revisionMember)
+ : this(revisionMember, NpgsqlDbType.Bigint)
{
+ }
+
+ public DocumentRevisionBinder(MemberInfo? revisionMember, NpgsqlDbType columnDbType)
+ {
+ // #4614: the mt_version column comes in two widths — bigint (default, used for
+ // ILongVersioned docs) or integer (used for IRevisioned docs, restored Marten 8
+ // behavior). The parameter NpgsqlDbType has to match the column type so Postgres
+ // doesn't refuse the bind on the strict VALUES (CASE … END) path.
+ _columnDbType = columnDbType;
+
if (revisionMember is not null)
{
- // #4526/#4528: the mt_version column is bigint, but the document member is
- // either an int (IRevisioned) or a long (ILongVersioned). For an int member
- // downcast the loaded long; this can overflow for huge MultiStreamProjection
- // event sequences — those documents should use ILongVersioned (documented
- // caveat).
+ // #4526/#4528: regardless of column width, the document member is either int
+ // (IRevisioned) or long (ILongVersioned). Read-side conversion (long → int)
+ // handles the rare overflow path; the integer column can't overflow into an
+ // int member, but a bigint column with an int member can. Those docs should
+ // use ILongVersioned (documented caveat).
if (revisionMember.GetRawMemberType() == typeof(int))
{
var intSetter = LambdaBuilder.Setter(revisionMember);
@@ -52,14 +64,24 @@ public DocumentRevisionBinder(MemberInfo? revisionMember)
}
}
+ public NpgsqlDbType ColumnDbType => _columnDbType;
+
public string ColumnName => Marten.Schema.SchemaConstants.VersionColumn;
public string ValueSql => "?";
public void BindParameter(NpgsqlParameter parameter, TDoc document, IMartenSession session)
{
- parameter.Value = 0L;
- parameter.NpgsqlDbType = NpgsqlDbType.Bigint;
+ if (_columnDbType == NpgsqlDbType.Integer)
+ {
+ parameter.Value = 0;
+ parameter.NpgsqlDbType = NpgsqlDbType.Integer;
+ }
+ else
+ {
+ parameter.Value = 0L;
+ parameter.NpgsqlDbType = NpgsqlDbType.Bigint;
+ }
}
public void Apply(DbDataReader reader, int columnOrdinal, TDoc document, IMartenSession session)
@@ -67,6 +89,8 @@ public void Apply(DbDataReader reader, int columnOrdinal, TDoc document, IMarten
if (_setter is null) return;
if (reader.IsDBNull(columnOrdinal)) return;
+ // Npgsql widens an int column to long via the field-value type handler, so
+ // reading as long works for both column widths.
var revision = reader.GetFieldValue(columnOrdinal);
_setter(document, revision);
}
@@ -83,8 +107,10 @@ public Task WriteToBulkAsync(NpgsqlBinaryImporter writer, TDoc document,
{
// Bulk path defaults to revision 1 — matches the codegen
// BulkLoader.GenerateBulkWriterCodeAsync's hard-coded "write
- // (long)1" for RevisionArgument.
+ // (long)1" for RevisionArgument. The parameter type follows the column.
_setter?.Invoke(document, 1L);
- return writer.WriteAsync(1L, NpgsqlDbType.Bigint, cancellation);
+ return _columnDbType == NpgsqlDbType.Integer
+ ? writer.WriteAsync(1, NpgsqlDbType.Integer, cancellation)
+ : writer.WriteAsync(1L, NpgsqlDbType.Bigint, cancellation);
}
}
diff --git a/src/Marten/Internal/ClosedShape/DocumentStorageDescriptorBuilder.cs b/src/Marten/Internal/ClosedShape/DocumentStorageDescriptorBuilder.cs
index 53fb559236..5525c55309 100644
--- a/src/Marten/Internal/ClosedShape/DocumentStorageDescriptorBuilder.cs
+++ b/src/Marten/Internal/ClosedShape/DocumentStorageDescriptorBuilder.cs
@@ -62,11 +62,16 @@ public static DocumentStorageDescriptor Build(
if (mapping.Metadata.Revision.Enabled)
{
- // Numeric revisions: mt_version is bigint. Revision and
- // Version columns share the same physical column name so
- // never both enabled — the validation in DocumentMapping
- // enforces it.
- revisionBinder = new DocumentRevisionBinder(mapping.Metadata.Revision.Member);
+ // Numeric revisions: mt_version is bigint (default) or integer (#4614,
+ // when IRevisioned-backed). Revision and Version columns share the same
+ // physical column name so never both enabled — the validation in
+ // DocumentMapping enforces it. The binder's parameter NpgsqlDbType has to
+ // match the column width so the CASE-in-VALUES bind doesn't trip Postgres'
+ // strict type check.
+ var revisionDbType = mapping.Metadata.Revision is Marten.Storage.Metadata.RevisionColumnInt32
+ ? NpgsqlTypes.NpgsqlDbType.Integer
+ : NpgsqlTypes.NpgsqlDbType.Bigint;
+ revisionBinder = new DocumentRevisionBinder(mapping.Metadata.Revision.Member, revisionDbType);
writeBinders.Add(revisionBinder);
// RevisionColumn.ShouldSelect: Member != null OR (!QueryOnly && UseNumericRevisions)
diff --git a/src/Marten/Metadata/VersionedPolicy.cs b/src/Marten/Metadata/VersionedPolicy.cs
index b8e1fa33e1..39e2ea0218 100644
--- a/src/Marten/Metadata/VersionedPolicy.cs
+++ b/src/Marten/Metadata/VersionedPolicy.cs
@@ -2,6 +2,7 @@
using JasperFx.Core;
using JasperFx.Core.Reflection;
using Marten.Schema;
+using Marten.Storage.Metadata;
using System.Diagnostics.CodeAnalysis;
namespace Marten.Metadata;
@@ -21,6 +22,12 @@ public void Apply(DocumentMapping mapping)
else if (mapping.DocumentType.CanBeCastTo())
{
+ // #4614: swap the default (bigint) Revision column for the integer variant.
+ // IRevisioned was the Marten 8 default, and the per-stream version it backs
+ // (SingleStreamProjection) is comfortably in Int32 range. Restoring the
+ // narrower column here means a V8→V9 upgrade no longer migrates the table
+ // from integer to bigint, and new schemas match the V8 shape exactly.
+ mapping.Metadata.Revision = new RevisionColumnInt32();
mapping.UseNumericRevisions = true;
mapping.Metadata.Revision.Enabled = true;
mapping.Metadata.Revision.Member = mapping.DocumentType.GetProperty(nameof(IRevisioned.Version));
@@ -28,7 +35,7 @@ public void Apply(DocumentMapping mapping)
// #4528: ILongVersioned is the 64-bit revision variant for documents projected
// from a MultiStreamProjection, where Version is the global event sequence number
- // and can exceed Int32.
+ // and can exceed Int32. Keeps the default RevisionColumn (bigint).
else if (mapping.DocumentType.CanBeCastTo())
{
mapping.UseNumericRevisions = true;
diff --git a/src/Marten/Schema/Arguments/RevisionArgument.cs b/src/Marten/Schema/Arguments/RevisionArgument.cs
index 740ba253ee..8a6ea352ea 100644
--- a/src/Marten/Schema/Arguments/RevisionArgument.cs
+++ b/src/Marten/Schema/Arguments/RevisionArgument.cs
@@ -12,3 +12,20 @@ public RevisionArgument()
Column = SchemaConstants.VersionColumn;
}
}
+
+///
+/// 32-bit (integer) revision argument for the IRevisioned column variant (#4614). The
+/// codegen UpsertFunction path uses this to declare the `revision` parameter as
+/// `integer` in `mt_upsert_*` functions whose target column is `integer`, so Postgres
+/// can bind without an implicit-cast mismatch.
+///
+internal class RevisionArgumentInt32: UpsertArgument
+{
+ public RevisionArgumentInt32()
+ {
+ Arg = "revision";
+ PostgresType = "integer";
+ DbType = NpgsqlDbType.Integer;
+ Column = SchemaConstants.VersionColumn;
+ }
+}
diff --git a/src/Marten/Schema/DocumentMetadataCollection.cs b/src/Marten/Schema/DocumentMetadataCollection.cs
index d1396582ff..8a56045e4d 100644
--- a/src/Marten/Schema/DocumentMetadataCollection.cs
+++ b/src/Marten/Schema/DocumentMetadataCollection.cs
@@ -11,7 +11,13 @@ public DocumentMetadataCollection(DocumentMapping parent)
}
public MetadataColumn Version { get; } = new VersionColumn();
- public MetadataColumn Revision { get; } = new RevisionColumn();
+
+ // #4614: VersionedPolicy reassigns this to RevisionColumnInt32 when the document
+ // type implements IRevisioned (int) so the mt_version column is `integer` rather
+ // than `bigint`, restoring the Marten 8 schema shape. ILongVersioned (long) docs
+ // keep the default RevisionColumn (bigint). The setter is internal because the
+ // variant choice is policy-driven, not user-driven.
+ public MetadataColumn Revision { get; internal set; } = new RevisionColumn();
public MetadataColumn LastModified { get; } = new LastModifiedColumn();
public MetadataColumn CreatedAt { get; } = new CreatedAtColumn();
public MetadataColumn TenantId { get; } = new TenantIdColumn();
diff --git a/src/Marten/Storage/DocumentTable.cs b/src/Marten/Storage/DocumentTable.cs
index 75436f133a..c1f58157fe 100644
--- a/src/Marten/Storage/DocumentTable.cs
+++ b/src/Marten/Storage/DocumentTable.cs
@@ -197,6 +197,10 @@ internal ISelectableColumn[] SelectColumns(StorageStyle style)
// Bug_3946 against MultiStreamProjection + conjoined tenancy.
ISelectableColumn? version = columns.OfType().SingleOrDefault();
version ??= columns.OfType().SingleOrDefault();
+ // #4614: the integer revision variant is a sibling class, not a subclass of
+ // RevisionColumn — has to be matched explicitly so the version slot stays
+ // canonical in the SELECT projection for IRevisioned-backed documents.
+ version ??= columns.OfType().SingleOrDefault();
var answer = new List();
diff --git a/src/Marten/Storage/Metadata/DocumentMetadata.cs b/src/Marten/Storage/Metadata/DocumentMetadata.cs
index 974d26b017..e492539c3b 100644
--- a/src/Marten/Storage/Metadata/DocumentMetadata.cs
+++ b/src/Marten/Storage/Metadata/DocumentMetadata.cs
@@ -30,6 +30,18 @@ public DocumentMetadata(object id)
///
public long CurrentRevision { get; internal set; }
+ ///
+ /// The current numeric revision when the document is mapped to the 32-bit (integer)
+ /// mt_version column variant (i.e. when its type implements ).
+ /// Shares storage with ; the int accessor is a view onto the
+ /// same persisted value, narrowed for the Int32 metadata-column read path (#4614).
+ ///
+ public int CurrentRevisionInt32
+ {
+ get => (int)CurrentRevision;
+ internal set => CurrentRevision = value;
+ }
+
///
/// Timestamp of the last time this document was modified
///
diff --git a/src/Marten/Storage/Metadata/RevisionColumn.cs b/src/Marten/Storage/Metadata/RevisionColumn.cs
index ac7299fd06..4709b36a3a 100644
--- a/src/Marten/Storage/Metadata/RevisionColumn.cs
+++ b/src/Marten/Storage/Metadata/RevisionColumn.cs
@@ -9,6 +9,20 @@
namespace Marten.Storage.Metadata;
+// #4614: the mt_version column for documents using numeric revisions comes in two
+// physical widths. SingleStreamProjection-projected documents implement IRevisioned
+// (int) and want `integer` — the per-stream version is bounded by the stream's event
+// count and was the Marten 8 default. MultiStreamProjection-projected documents
+// implement ILongVersioned (long) and want `bigint` because the version is sourced
+// from the global event sequence number and can exceed Int32. VersionedPolicy chooses
+// the right variant when binding the column to the document type.
+
+///
+/// The 64-bit (bigint) revision column variant. Used when the document type
+/// implements . Tolerates a pre-existing
+/// integer column on disk by widening it (non-destructively) to bigint —
+/// preserves the Marten 8 → 9 upgrade path for ILongVersioned documents.
+///
internal class RevisionColumn: MetadataColumn, ISelectableColumn
{
public RevisionColumn(): base(SchemaConstants.VersionColumn, x => x.CurrentRevision)
@@ -67,3 +81,76 @@ public override void WriteMetadataInUpdateStatement(ICommandBuilder builder, Doc
builder.Append(" + 1");
}
}
+
+///
+/// The 32-bit (integer) revision column variant — the Marten 8 default that
+/// restores when the document type implements
+/// . Tolerates a pre-existing bigint
+/// column on disk (already-on-9.x deployments are not force-narrowed); the
+/// schema diff treats either width as acceptable to avoid lossy migrations.
+///
+internal class RevisionColumnInt32: MetadataColumn, ISelectableColumn
+{
+ public RevisionColumnInt32(): base(SchemaConstants.VersionColumn, x => x.CurrentRevisionInt32)
+ {
+ AllowNulls = false;
+ DefaultExpression = "0";
+ Enabled = false;
+ ShouldUpdatePartials = true;
+ }
+
+ internal override UpsertArgument ToArgument()
+ {
+ return new RevisionArgumentInt32();
+ }
+
+ // Same dual-acceptance as the long variant: either int member (IRevisioned, natural
+ // here) or long member (a user who manually wires ILongVersioned onto a SingleStream
+ // doc). DocumentRevisionBinder reconciles the member width with the column width.
+ protected override bool IsAcceptableMemberType(Type memberType)
+ => memberType == typeof(long) || memberType == typeof(int);
+
+ public bool ShouldSelect(DocumentMapping mapping, StorageStyle storageStyle)
+ {
+ if (Member != null)
+ {
+ return true;
+ }
+
+ return storageStyle != StorageStyle.QueryOnly && mapping.UseNumericRevisions;
+ }
+
+ public override string AlterColumnTypeSql(Table table, TableColumn changeActual)
+ {
+ // #4614: existing 9.x deployments already migrated mt_version to bigint when
+ // RevisionColumn was the only variant. Now that IRevisioned-backed documents
+ // want integer again, we deliberately tolerate the wider column on disk
+ // rather than emit a lossy `USING mt_version::integer` narrowing cast — any
+ // legitimate IRevisioned data is comfortably in Int32 range, but Postgres
+ // refuses to verify that without scanning the table and a forced narrow risks
+ // silent data loss if anything slipped through. Tolerate => no-op.
+ if (changeActual.Type.EqualsIgnoreCase("bigint"))
+ {
+ return string.Empty;
+ }
+
+ // uuid (Guid concurrency mode) — drop and recreate as integer.
+ return $"ALTER TABLE {table.Identifier.QualifiedName} DROP COLUMN {Name};{AddColumnSql(table)}";
+ }
+
+ public override bool CanAlter(TableColumn actual)
+ {
+ // bigint is reported as acceptable so the diff machinery routes it through
+ // AlterColumnTypeSql above (which then no-ops) rather than re-creating the
+ // column with potential constraint loss.
+ return actual.Type.EqualsIgnoreCase("uuid") || actual.Type.EqualsIgnoreCase("bigint");
+ }
+
+ public override void WriteMetadataInUpdateStatement(ICommandBuilder builder, DocumentSessionBase session)
+ {
+ builder.Append(SchemaConstants.VersionColumn);
+ builder.Append(" = ");
+ builder.Append(SchemaConstants.VersionColumn);
+ builder.Append(" + 1");
+ }
+}