diff --git a/src/BLite.Bson/BsonSpanReader.cs b/src/BLite.Bson/BsonSpanReader.cs
index aab4d08d..559d22c7 100644
--- a/src/BLite.Bson/BsonSpanReader.cs
+++ b/src/BLite.Bson/BsonSpanReader.cs
@@ -209,6 +209,17 @@ public int ReadInt32()
return value;
}
+ /// Not a standalone BSON element type - used only for the offset-in-minutes trailer in .
+ private short ReadInt16()
+ {
+ if (Remaining < 2)
+ throw new InvalidOperationException("Not enough bytes to read Int16");
+
+ var value = BinaryPrimitives.ReadInt16LittleEndian(_buffer.Slice(_position, 2));
+ _position += 2;
+ return value;
+ }
+
public long ReadInt64()
{
if (Remaining < 8)
@@ -323,7 +334,11 @@ public DateTime ReadDateTime()
}
///
- /// Reads a BSON DateTime as DateTimeOffset (UTC milliseconds since Unix epoch)
+ /// Reads a legacy BSON DateTime field (8-byte UTC millisecond timestamp) as a DateTimeOffset.
+ /// There is no offset on the wire in this format, so the result always has Offset=0 - use
+ /// when the wire type is known, so a value written
+ /// by WriteDateTimeOffset (tagged ) comes back with
+ /// its original offset instead.
///
public DateTimeOffset ReadDateTimeOffset()
{
@@ -331,6 +346,26 @@ public DateTimeOffset ReadDateTimeOffset()
return DateTimeOffset.FromUnixTimeMilliseconds(milliseconds);
}
+ ///
+ /// Reads a DateTimeOffset field, coercing a legacy wire value
+ /// (8 bytes, no offset - always predates this type, see )
+ /// the same way does, or decoding the full 10-byte
+ /// format (UTC milliseconds + signed offset-in-minutes)
+ /// when the offset was actually preserved on write.
+ ///
+ public DateTimeOffset ReadDateTimeOffset(BsonType bsonType)
+ {
+ if (bsonType != BsonType.DateTimeOffset)
+ {
+ return ReadDateTimeOffset();
+ }
+
+ var milliseconds = ReadInt64();
+ var offsetMinutes = ReadInt16();
+ var instant = DateTimeOffset.FromUnixTimeMilliseconds(milliseconds);
+ return instant.ToOffset(TimeSpan.FromMinutes(offsetMinutes));
+ }
+
///
/// Reads a TimeSpan from BSON Int64 (ticks)
///
@@ -433,6 +468,9 @@ public void SkipValue(BsonType type)
case BsonType.Timestamp:
_position += 8;
break;
+ case BsonType.DateTimeOffset:
+ _position += 10; // 8-byte UTC millisecond timestamp + 2-byte offset-in-minutes
+ break;
case BsonType.Decimal128:
_position += 16;
break;
diff --git a/src/BLite.Bson/BsonSpanWriter.cs b/src/BLite.Bson/BsonSpanWriter.cs
index 72b6c9c3..e76bf5ae 100644
--- a/src/BLite.Bson/BsonSpanWriter.cs
+++ b/src/BLite.Bson/BsonSpanWriter.cs
@@ -255,12 +255,22 @@ public void WriteDateTime(string name, DateTime value)
_position += 8;
}
+ ///
+ /// Writes a UTC instant plus its original offset (10 bytes: 8-byte UTC millisecond timestamp,
+ /// same layout as , followed by a 2-byte signed offset in minutes).
+ /// Tagged , not , so
+ /// and typed readers know an extra 2 bytes follow and
+ /// can reconstruct the original wall-clock value instead of always normalising to UTC.
+ ///
public void WriteDateTimeOffset(string name, DateTimeOffset value)
{
- WriteElementHeader(BsonType.DateTime, name);
+ WriteElementHeader(BsonType.DateTimeOffset, name);
var milliseconds = value.ToUnixTimeMilliseconds();
BinaryPrimitives.WriteInt64LittleEndian(_buffer.Slice(_position, 8), milliseconds);
_position += 8;
+ var offsetMinutes = (short)value.Offset.TotalMinutes;
+ BinaryPrimitives.WriteInt16LittleEndian(_buffer.Slice(_position, 2), offsetMinutes);
+ _position += 2;
}
public void WriteTimeSpan(string name, TimeSpan value)
@@ -448,12 +458,16 @@ public void WriteArrayDateTime(int index, DateTime value)
_position += 8;
}
+ /// Array counterpart of - see its remarks.
public void WriteArrayDateTimeOffset(int index, DateTimeOffset value)
{
- WriteArrayElementHeader(BsonType.DateTime, index);
+ WriteArrayElementHeader(BsonType.DateTimeOffset, index);
var milliseconds = value.ToUnixTimeMilliseconds();
BinaryPrimitives.WriteInt64LittleEndian(_buffer.Slice(_position, 8), milliseconds);
_position += 8;
+ var offsetMinutes = (short)value.Offset.TotalMinutes;
+ BinaryPrimitives.WriteInt16LittleEndian(_buffer.Slice(_position, 2), offsetMinutes);
+ _position += 2;
}
public void WriteArrayTimeSpan(int index, TimeSpan value)
diff --git a/src/BLite.Bson/BsonType.cs b/src/BLite.Bson/BsonType.cs
index 729199a6..fa64c8e6 100644
--- a/src/BLite.Bson/BsonType.cs
+++ b/src/BLite.Bson/BsonType.cs
@@ -25,6 +25,17 @@ public enum BsonType : byte
Timestamp = 0x11,
Int64 = 0x12,
Decimal128 = 0x13,
+
+ ///
+ /// BLite extension, not part of the BSON spec (0x14 is unassigned there). Distinct from
+ /// because it carries a UTC-offset alongside the instant - see
+ /// / BsonSpanWriter.WriteDateTimeOffset.
+ /// A value written before this type existed is still tagged
+ /// on disk (offset already lost at write time, not recoverable) -
+ /// readers must keep handling that tag for fields too.
+ ///
+ DateTimeOffset = 0x14,
+
MinKey = 0xFF,
MaxKey = 0x7F
}
diff --git a/src/BLite.Bson/BsonValue.cs b/src/BLite.Bson/BsonValue.cs
index ce0be431..6d5a73b0 100644
--- a/src/BLite.Bson/BsonValue.cs
+++ b/src/BLite.Bson/BsonValue.cs
@@ -43,7 +43,15 @@ private BsonValue(BsonType type, double numericValue = 0, object? refValue = nul
public static BsonValue FromBoolean(bool value) => new(BsonType.Boolean, value ? 1 : 0);
public static BsonValue FromObjectId(ObjectId value) => new(BsonType.ObjectId, refValue: value);
public static BsonValue FromDateTime(DateTime value) => new(BsonType.DateTime, BitConverter.Int64BitsToDouble(new DateTimeOffset(value.ToUniversalTime()).ToUnixTimeMilliseconds()));
- public static BsonValue FromDateTimeOffset(DateTimeOffset value) => new(BsonType.DateTime, BitConverter.Int64BitsToDouble(value.ToUnixTimeMilliseconds()));
+
+ ///
+ /// Tagged , not , so the
+ /// offset survives a round-trip through / instead of
+ /// being silently normalised to UTC. The offset itself rides in _refValue (boxed
+ /// minutes) since _numericValue already holds the packed instant.
+ ///
+ public static BsonValue FromDateTimeOffset(DateTimeOffset value) =>
+ new(BsonType.DateTimeOffset, BitConverter.Int64BitsToDouble(value.ToUnixTimeMilliseconds()), (short)value.Offset.TotalMinutes);
public static BsonValue FromGuid(Guid value) => new(BsonType.String, refValue: value.ToString());
public static BsonValue FromBinary(byte[] value) => new(BsonType.Binary, refValue: value ?? throw new ArgumentNullException(nameof(value)));
public static BsonValue FromDocument(BsonDocument value) => new(BsonType.Document, refValue: value ?? throw new ArgumentNullException(nameof(value)));
@@ -110,13 +118,29 @@ private BsonValue(BsonType type, double numericValue = 0, object? refValue = nul
? oid
: throw new InvalidOperationException($"BsonValue is {_type}, not ObjectId");
- public DateTime AsDateTime => _type == BsonType.DateTime
- ? DateTimeOffset.FromUnixTimeMilliseconds(BitConverter.DoubleToInt64Bits(_numericValue)).UtcDateTime
- : throw new InvalidOperationException($"BsonValue is {_type}, not DateTime");
+ public DateTime AsDateTime => _type switch
+ {
+ BsonType.DateTime or BsonType.DateTimeOffset =>
+ DateTimeOffset.FromUnixTimeMilliseconds(BitConverter.DoubleToInt64Bits(_numericValue)).UtcDateTime,
+ _ => throw new InvalidOperationException($"BsonValue is {_type}, not DateTime")
+ };
- public DateTimeOffset AsDateTimeOffset => _type == BsonType.DateTime
- ? DateTimeOffset.FromUnixTimeMilliseconds(BitConverter.DoubleToInt64Bits(_numericValue))
- : throw new InvalidOperationException($"BsonValue is {_type}, not DateTime");
+ ///
+ /// For (a value written before offsets were tracked, or a plain
+ /// field) the offset is unknown and comes back as 0 (UTC). For
+ /// the original offset (boxed in _refValue) is
+ /// restored via , which re-expresses the same instant -
+ /// it does not shift it.
+ ///
+ public DateTimeOffset AsDateTimeOffset => _type switch
+ {
+ BsonType.DateTimeOffset =>
+ DateTimeOffset.FromUnixTimeMilliseconds(BitConverter.DoubleToInt64Bits(_numericValue))
+ .ToOffset(TimeSpan.FromMinutes((short)_refValue!)),
+ BsonType.DateTime =>
+ DateTimeOffset.FromUnixTimeMilliseconds(BitConverter.DoubleToInt64Bits(_numericValue)),
+ _ => throw new InvalidOperationException($"BsonValue is {_type}, not DateTime")
+ };
public byte[] AsBinary => _type == BsonType.Binary && _refValue is byte[] b
? b
@@ -149,6 +173,7 @@ private BsonValue(BsonType type, double numericValue = 0, object? refValue = nul
public bool IsDouble => Type == BsonType.Double;
public bool IsBoolean => Type == BsonType.Boolean;
public bool IsDateTime => Type == BsonType.DateTime;
+ public bool IsDateTimeOffset => Type == BsonType.DateTimeOffset;
public bool IsDecimal => Type == BsonType.Decimal128;
public bool IsBinary => Type == BsonType.Binary;
public bool IsObjectId => Type == BsonType.ObjectId;
@@ -196,6 +221,9 @@ public void WriteTo(ref BsonSpanWriter writer, string fieldName)
var dt = DateTimeOffset.FromUnixTimeMilliseconds(BitConverter.DoubleToInt64Bits(_numericValue)).UtcDateTime;
writer.WriteDateTime(fieldName, dt);
break;
+ case BsonType.DateTimeOffset:
+ writer.WriteDateTimeOffset(fieldName, AsDateTimeOffset);
+ break;
case BsonType.Binary:
writer.WriteBinary(fieldName, (byte[])_refValue!);
break;
@@ -256,6 +284,9 @@ public void WriteToArray(ref BsonSpanWriter writer, int index)
case BsonType.DateTime:
writer.WriteArrayDateTime(index, DateTimeOffset.FromUnixTimeMilliseconds(BitConverter.DoubleToInt64Bits(_numericValue)).UtcDateTime);
break;
+ case BsonType.DateTimeOffset:
+ writer.WriteArrayDateTimeOffset(index, AsDateTimeOffset);
+ break;
case BsonType.Null:
writer.WriteArrayNull(index);
break;
@@ -292,7 +323,13 @@ public static BsonValue ReadFrom(ref BsonSpanReader reader, BsonType type)
BsonType.String => FromString(reader.ReadString()),
BsonType.Boolean => FromBoolean(reader.ReadBoolean()),
BsonType.ObjectId => FromObjectId(reader.ReadObjectId()),
- BsonType.DateTime => FromDateTimeOffset(reader.ReadDateTimeOffset()),
+ // Must stay tagged BsonType.DateTime, matching the wire tag just read - not
+ // FromDateTimeOffset, which now tags BsonType.DateTimeOffset. Using it here would flip
+ // the in-memory type for every plain DateTime field (and every legacy DateTimeOffset
+ // value still under the old tag), so a later WriteTo would silently re-emit it in the
+ // new 10-byte layout even though nothing about its offset was actually recovered.
+ BsonType.DateTime => FromDateTime(reader.ReadDateTime()),
+ BsonType.DateTimeOffset => FromDateTimeOffset(reader.ReadDateTimeOffset(BsonType.DateTimeOffset)),
BsonType.Null => Null,
BsonType.Binary => FromBinary(reader.ReadBinary(out _).ToArray()),
BsonType.Array => ReadArray(ref reader),
@@ -335,6 +372,11 @@ public bool Equals(BsonValue other)
{
BsonType.Int32 or BsonType.Int64 or BsonType.Double or BsonType.Boolean or BsonType.DateTime
=> _numericValue == other._numericValue,
+ // Unlike DateTimeOffset.Equals (instant-only), the offset is treated as significant here:
+ // it's the whole reason this type exists, so two values landing on the same instant but
+ // written with a different offset are NOT the same BsonValue. Keeps this consistent with
+ // GetHashCode, which already combines both _numericValue and _refValue (the boxed offset).
+ BsonType.DateTimeOffset => _numericValue == other._numericValue && (short)_refValue! == (short)other._refValue!,
BsonType.String => string.Equals((string?)_refValue, (string?)other._refValue, StringComparison.Ordinal),
BsonType.ObjectId => Equals(_refValue, other._refValue),
BsonType.Null => true,
@@ -361,6 +403,7 @@ public override string ToString()
BsonType.Boolean => (_numericValue != 0).ToString(),
BsonType.ObjectId => _refValue?.ToString() ?? "(null)",
BsonType.DateTime => AsDateTime.ToString("O"),
+ BsonType.DateTimeOffset => AsDateTimeOffset.ToString("O"),
BsonType.Null => "null",
_ => $"({_type})"
};
diff --git a/src/BLite.Core/Collections/BsonSchemaGenerator.cs b/src/BLite.Core/Collections/BsonSchemaGenerator.cs
index 07c969a7..b4efc13c 100644
--- a/src/BLite.Core/Collections/BsonSchemaGenerator.cs
+++ b/src/BLite.Core/Collections/BsonSchemaGenerator.cs
@@ -115,7 +115,8 @@ private static (BsonType type, BsonSchema? nested, BsonType? itemType) GetBsonTy
if (type == typeof(bool)) return (BsonType.Boolean, null, null);
if (type == typeof(double)) return (BsonType.Double, null, null);
if (type == typeof(decimal)) return (BsonType.Decimal128, null, null);
- if (type == typeof(DateTime) || type == typeof(DateTimeOffset)) return (BsonType.DateTime, null, null);
+ if (type == typeof(DateTime)) return (BsonType.DateTime, null, null);
+ if (type == typeof(DateTimeOffset)) return (BsonType.DateTimeOffset, null, null);
if (type == typeof(Guid)) return (BsonType.Binary, null, null); // Guid is usually Binary subtype
if (type == typeof(byte[])) return (BsonType.Binary, null, null);
diff --git a/src/BLite.Core/Collections/DocumentCollection.cs b/src/BLite.Core/Collections/DocumentCollection.cs
index f02d6ff2..7d0f6c3f 100644
--- a/src/BLite.Core/Collections/DocumentCollection.cs
+++ b/src/BLite.Core/Collections/DocumentCollection.cs
@@ -408,7 +408,7 @@ private long ExtractTimestampTicks(byte[] bsonBytes, string fieldName)
var value = BsonValue.ReadFrom(ref reader, seekType);
return value.Type switch
{
- BsonType.DateTime => value.AsDateTime.Ticks,
+ BsonType.DateTime or BsonType.DateTimeOffset => value.AsDateTime.Ticks,
BsonType.Int64 => value.AsInt64,
_ => 0
};
@@ -429,7 +429,7 @@ private long ExtractTimestampTicks(byte[] bsonBytes, string fieldName)
var val = BsonValue.ReadFrom(ref reader, type);
return val.Type switch
{
- BsonType.DateTime => val.AsDateTime.Ticks,
+ BsonType.DateTime or BsonType.DateTimeOffset => val.AsDateTime.Ticks,
BsonType.Int64 => val.AsInt64,
_ => 0
};
diff --git a/src/BLite.Core/DynamicCollection.cs b/src/BLite.Core/DynamicCollection.cs
index 5711a0e3..81a07a6d 100644
--- a/src/BLite.Core/DynamicCollection.cs
+++ b/src/BLite.Core/DynamicCollection.cs
@@ -423,7 +423,7 @@ private async Task ApplyRetentionPolicyCoreAsync(CancellationToken ct)
{
ticks = tsVal.Type switch
{
- BsonType.DateTime => tsVal.AsDateTime.Ticks,
+ BsonType.DateTime or BsonType.DateTimeOffset => tsVal.AsDateTime.Ticks,
BsonType.Int64 => tsVal.AsInt64,
_ => 0
};
@@ -2001,8 +2001,10 @@ private void SnapshotFsiForTransaction(ITransaction transaction, uint pageId)
BsonType.ObjectId => new IndexKey(value.AsObjectId),
BsonType.Double => new IndexKey(BitConverter.GetBytes(value.AsDouble)),
// DateTime is stored as BitConverter.Int64BitsToDouble(unixMs); key on the raw long
- // to get the same ordering used by ToIndexObject in BlqlFilter.
- BsonType.DateTime => new IndexKey(value.AsDateTimeOffset.ToUnixTimeMilliseconds()),
+ // to get the same ordering used by ToIndexObject in BlqlFilter. Deliberately keyed by
+ // instant, not offset, for DateTimeOffset too - the offset must not perturb range-scan
+ // ordering, only how the value is displayed once read back.
+ BsonType.DateTime or BsonType.DateTimeOffset => new IndexKey(value.AsDateTimeOffset.ToUnixTimeMilliseconds()),
_ => null // Can't index this type as BTree key
};
}
diff --git a/src/BLite.Core/Query/Blql/BlqlFilter.cs b/src/BLite.Core/Query/Blql/BlqlFilter.cs
index e13a99c1..672b1f10 100644
--- a/src/BLite.Core/Query/Blql/BlqlFilter.cs
+++ b/src/BLite.Core/Query/Blql/BlqlFilter.cs
@@ -384,7 +384,8 @@ internal override bool TryGetIndexCandidate(out IndexScanCandidate candidate)
BsonType.ObjectId => v.AsObjectId,
// DateTime is stored as BitConverter.Int64BitsToDouble(unixMs); extract as long so
// CreateIndexKeyFromObject can create a comparable IndexKey(long) for BTree range scans.
- BsonType.DateTime => v.AsDateTimeOffset.ToUnixTimeMilliseconds(),
+ // Keyed by instant for DateTimeOffset too, matching DynamicCollection.ToIndexObject.
+ BsonType.DateTime or BsonType.DateTimeOffset => v.AsDateTimeOffset.ToUnixTimeMilliseconds(),
_ => null
};
}
diff --git a/src/BLite.Core/Query/Blql/BsonValueComparer.cs b/src/BLite.Core/Query/Blql/BsonValueComparer.cs
index d0153503..f2814420 100644
--- a/src/BLite.Core/Query/Blql/BsonValueComparer.cs
+++ b/src/BLite.Core/Query/Blql/BsonValueComparer.cs
@@ -26,6 +26,14 @@ public static int Compare(BsonValue a, BsonValue b)
if (IsNumeric(a.Type) && IsNumeric(b.Type))
return ToDouble(a).CompareTo(ToDouble(b));
+ // DateTime/DateTimeOffset: compare by instant across the two tags too, same as the numeric
+ // family above. A collection can hold both during the transition after upgrading BLite - a
+ // DateTimeOffset value written before this type existed stays tagged DateTime forever - so
+ // this must not fall through to TypeOrder below, which would sort DateTimeOffset-tagged rows
+ // as unrelated to DateTime-tagged ones instead of interleaving them by actual time.
+ if (IsDateLike(a.Type) && IsDateLike(b.Type))
+ return a.AsDateTimeOffset.CompareTo(b.AsDateTimeOffset);
+
// Same type comparisons
if (a.Type == b.Type)
{
@@ -33,7 +41,7 @@ public static int Compare(BsonValue a, BsonValue b)
{
BsonType.String => string.Compare(a.AsString, b.AsString, StringComparison.Ordinal),
BsonType.Boolean => a.AsBoolean.CompareTo(b.AsBoolean),
- BsonType.DateTime => a.AsDateTimeOffset.CompareTo(b.AsDateTimeOffset),
+ BsonType.DateTime or BsonType.DateTimeOffset => a.AsDateTimeOffset.CompareTo(b.AsDateTimeOffset),
BsonType.ObjectId => CompareObjectIds(a.AsObjectId, b.AsObjectId),
BsonType.Binary => CompareBytes(a.AsBinary, b.AsBinary),
_ => 0
@@ -47,6 +55,9 @@ public static int Compare(BsonValue a, BsonValue b)
private static bool IsNumeric(BsonType t) =>
t is BsonType.Int32 or BsonType.Int64 or BsonType.Double or BsonType.Decimal128;
+ private static bool IsDateLike(BsonType t) =>
+ t is BsonType.DateTime or BsonType.DateTimeOffset;
+
private static double ToDouble(BsonValue v) => v.Type switch
{
BsonType.Int32 => v.AsInt32,
@@ -67,6 +78,7 @@ private static bool IsNumeric(BsonType t) =>
BsonType.String => 3,
BsonType.ObjectId => 4,
BsonType.DateTime => 5,
+ BsonType.DateTimeOffset => 5,
BsonType.Binary => 6,
BsonType.Document => 7,
BsonType.Array => 8,
diff --git a/src/BLite.Core/Query/BsonExpressionEvaluator.cs b/src/BLite.Core/Query/BsonExpressionEvaluator.cs
index ae3a88e5..30a3e1fc 100644
--- a/src/BLite.Core/Query/BsonExpressionEvaluator.cs
+++ b/src/BLite.Core/Query/BsonExpressionEvaluator.cs
@@ -596,6 +596,7 @@ private static BsonReaderPredicate CreateIsNullOrEmptyPredicate(string fieldName
BsonType.Boolean => reader.ReadBoolean(),
BsonType.ObjectId => reader.ReadObjectId(),
BsonType.DateTime => reader.ReadDateTime(),
+ BsonType.DateTimeOffset => reader.ReadDateTimeOffset(BsonType.DateTimeOffset),
BsonType.Null => null,
_ => null
};
@@ -652,6 +653,7 @@ internal static BsonReaderPredicate CreateInPredicateDirect(
BsonType.Boolean => reader.ReadBoolean(),
BsonType.ObjectId => reader.ReadObjectId(),
BsonType.DateTime => reader.ReadDateTime(),
+ BsonType.DateTimeOffset => reader.ReadDateTimeOffset(BsonType.DateTimeOffset),
BsonType.Null => null,
_ => null
};
@@ -1087,9 +1089,14 @@ private static bool Compare(ref BsonSpanReader reader, BsonType type, object? ta
};
}
}
- else if (type == BsonType.DateTime)
+ else if (type == BsonType.DateTime || type == BsonType.DateTimeOffset)
{
- var val = reader.ReadDateTime();
+ // Both tags land here: the comparisons below normalise everything to UTC ticks, so the
+ // offset (only relevant to how a DateTimeOffset value is displayed, not compared) doesn't
+ // need special handling once decoded - only the read itself must pick the matching layout.
+ var val = type == BsonType.DateTimeOffset
+ ? reader.ReadDateTimeOffset(BsonType.DateTimeOffset).UtcDateTime
+ : reader.ReadDateTime();
if (target is DateTime targetDt)
{
// Normalise both sides to UTC ticks for a reliable comparison.
diff --git a/src/BLite.Core/Query/BsonProjectionCompiler.cs b/src/BLite.Core/Query/BsonProjectionCompiler.cs
index b4e5f79e..db0cf07b 100644
--- a/src/BLite.Core/Query/BsonProjectionCompiler.cs
+++ b/src/BLite.Core/Query/BsonProjectionCompiler.cs
@@ -147,6 +147,9 @@ internal static class BsonProjectionCompiler
BsonType.ObjectId => reader.ReadObjectId(),
BsonType.Boolean => reader.ReadBoolean(),
BsonType.DateTime => reader.ReadDateTime(),
+ // Own case, not folded into DateTime above: the wire layouts differ (10
+ // bytes vs 8), reading the wrong one desyncs every field read after it.
+ BsonType.DateTimeOffset => (object?)reader.ReadDateTimeOffset(BsonType.DateTimeOffset),
BsonType.Int32 => reader.ReadInt32(),
BsonType.Int64 => reader.ReadInt64(),
BsonType.Decimal128 => reader.ReadDecimal128(),
@@ -178,6 +181,7 @@ internal static class BsonProjectionCompiler
case BsonType.ObjectId: values[idx] = reader.ReadObjectId(); break;
case BsonType.Boolean: values[idx] = reader.ReadBoolean(); break;
case BsonType.DateTime: values[idx] = reader.ReadDateTime(); break;
+ case BsonType.DateTimeOffset: values[idx] = reader.ReadDateTimeOffset(BsonType.DateTimeOffset); break;
case BsonType.Int32: values[idx] = reader.ReadInt32(); break;
case BsonType.Int64: values[idx] = reader.ReadInt64(); break;
case BsonType.Decimal128: values[idx] = reader.ReadDecimal128(); break;
diff --git a/src/BLite.Core/Storage/StorageEngine.Collections.cs b/src/BLite.Core/Storage/StorageEngine.Collections.cs
index a0b0a3e0..6834d957 100644
--- a/src/BLite.Core/Storage/StorageEngine.Collections.cs
+++ b/src/BLite.Core/Storage/StorageEngine.Collections.cs
@@ -67,6 +67,7 @@ public string BuildText(BsonDocument document)
BsonType.Array => string.Join(" ", value.AsArray.Select(ValueToString)),
BsonType.ObjectId => value.AsObjectId.ToString(),
BsonType.DateTime => value.AsDateTime.ToString("O"),
+ BsonType.DateTimeOffset => value.AsDateTimeOffset.ToString("O"),
_ => null
} ?? string.Empty;
}
diff --git a/src/BLite.Core/Storage/StorageEngine.TimeSeries.cs b/src/BLite.Core/Storage/StorageEngine.TimeSeries.cs
index 1c4360cb..dc2dbbe8 100644
--- a/src/BLite.Core/Storage/StorageEngine.TimeSeries.cs
+++ b/src/BLite.Core/Storage/StorageEngine.TimeSeries.cs
@@ -23,7 +23,7 @@ public DocumentLocation InsertTimeSeries(string collectionName, BsonDocument doc
long timestamp = 0;
if (meta.TtlFieldName != null && document.TryGetValue(meta.TtlFieldName, out var val))
{
- if (val.Type == BsonType.DateTime)
+ if (val.Type == BsonType.DateTime || val.Type == BsonType.DateTimeOffset)
timestamp = val.AsDateTime.Ticks;
else if (val.Type == BsonType.Int64)
timestamp = val.AsInt64;
diff --git a/src/BLite.Core/Text/TextNormalizer.cs b/src/BLite.Core/Text/TextNormalizer.cs
index 487cc443..b21678a2 100644
--- a/src/BLite.Core/Text/TextNormalizer.cs
+++ b/src/BLite.Core/Text/TextNormalizer.cs
@@ -110,6 +110,7 @@ public static string BuildEmbeddingText(BsonDocument document, VectorSourceConfi
BsonType.Array => string.Join(" ", value.AsArray.Select(BsonValueToString)),
BsonType.ObjectId => value.AsObjectId.ToString(),
BsonType.DateTime => value.AsDateTime.ToString("O"),
+ BsonType.DateTimeOffset => value.AsDateTimeOffset.ToString("O"),
_ => string.Empty
};
}
diff --git a/src/BLite.SourceGenerators/CodeGenerator.cs b/src/BLite.SourceGenerators/CodeGenerator.cs
index 9bd004fd..71c2043c 100644
--- a/src/BLite.SourceGenerators/CodeGenerator.cs
+++ b/src/BLite.SourceGenerators/CodeGenerator.cs
@@ -1173,7 +1173,12 @@ private static string GetBaseMapperClass(PropertyInfo? keyProp, EntityInfo entit
}
private static bool IsCoercedReadMethod(string? readMethod)
- => readMethod is "ReadInt32Coerced" or "ReadInt64Coerced" or "ReadDoubleCoerced";
+ // ReadDateTimeOffset(BsonType) isn't "coerced" in the numeric-widening sense of the others,
+ // but needs the wire type for the same reason: a DateTimeOffset property predating this type
+ // may still be stored under the legacy BsonType.DateTime tag (offset already lost at write
+ // time, not recoverable) and must decode with that tag's 8-byte layout instead of the new
+ // 10-byte one - see BsonType.DateTimeOffset.
+ => readMethod is "ReadInt32Coerced" or "ReadInt64Coerced" or "ReadDoubleCoerced" or "ReadDateTimeOffset";
private static bool IsValueType(string typeName)
{
diff --git a/tests/BLite.Tests/BsonSpanReaderWriterTests.cs b/tests/BLite.Tests/BsonSpanReaderWriterTests.cs
index aceb83e1..5f083b44 100644
--- a/tests/BLite.Tests/BsonSpanReaderWriterTests.cs
+++ b/tests/BLite.Tests/BsonSpanReaderWriterTests.cs
@@ -154,6 +154,113 @@ public void WriteAndRead_DateTime()
Assert.Equal(expectedTime, readTime);
}
+ [Fact]
+ public void WriteAndRead_DateTimeOffset_PreservesOffset()
+ {
+ Span buffer = stackalloc byte[256];
+ var writer = new BsonSpanWriter(buffer, _keyMap);
+
+ // A non-UTC offset (e.g. CEST, UTC+2): the instant this represents is 18:00 UTC, and the
+ // wall-clock value callers expect back is 20:00+02:00, not 18:00+00:00.
+ var original = new DateTimeOffset(2026, 6, 15, 20, 0, 0, TimeSpan.FromHours(2));
+
+ var sizePos = writer.BeginDocument();
+ writer.WriteDateTimeOffset("timestamp", original);
+ writer.EndDocument(sizePos);
+
+ var documentBytes = buffer[..writer.Position];
+ var reader = new BsonSpanReader(documentBytes, _keys);
+
+ reader.ReadDocumentSize();
+ var type = reader.ReadBsonType();
+ reader.ReadElementHeader();
+
+ // The wire tag is BsonType.DateTimeOffset - a value written by WriteDateTimeOffset never
+ // uses the plain BsonType.DateTime tag, so a reader can always tell whether the offset was
+ // actually preserved on write, without knowing the C# property type in advance.
+ Assert.Equal(BsonType.DateTimeOffset, type);
+
+ var readBack = reader.ReadDateTimeOffset(type);
+
+ // Both the instant and the original offset must survive the round-trip.
+ Assert.Equal(original.ToUniversalTime(), readBack.ToUniversalTime());
+ Assert.Equal(original.Offset, readBack.Offset);
+ Assert.Equal(original, readBack);
+ }
+
+ [Fact]
+ public void ReadDateTimeOffset_LegacyDateTimeTag_StillDecodesAsOffsetZero()
+ {
+ // Back-compat characterisation: a DateTimeOffset field written before BsonType.DateTimeOffset
+ // existed is on disk under the plain BsonType.DateTime tag (8 bytes, no offset - the offset
+ // was already lost forever at write time, not something a reader can recover). Reading that
+ // legacy tag through the new ReadDateTimeOffset(BsonType) overload must decode it exactly as
+ // it always has - Offset=0 - rather than misreading it as the new 10-byte layout.
+ Span buffer = stackalloc byte[256];
+ var writer = new BsonSpanWriter(buffer, _keyMap);
+
+ var legacyUtc = new DateTime(2026, 6, 15, 18, 0, 0, DateTimeKind.Utc);
+
+ var sizePos = writer.BeginDocument();
+ writer.WriteDateTime("timestamp", legacyUtc);
+ writer.EndDocument(sizePos);
+
+ var documentBytes = buffer[..writer.Position];
+ var reader = new BsonSpanReader(documentBytes, _keys);
+
+ reader.ReadDocumentSize();
+ var type = reader.ReadBsonType();
+ reader.ReadElementHeader();
+
+ Assert.Equal(BsonType.DateTime, type);
+
+ var readBack = reader.ReadDateTimeOffset(type);
+
+ Assert.Equal(legacyUtc, readBack.UtcDateTime);
+ Assert.Equal(TimeSpan.Zero, readBack.Offset);
+ }
+
+ [Fact]
+ public void BsonValue_ReadFrom_LegacyDateTimeTag_StaysTaggedDateTime()
+ {
+ // Regression for a review finding on the DateTimeOffset fix: BsonValue.ReadFrom must not
+ // reuse FromDateTimeOffset (now tagged BsonType.DateTimeOffset) to decode a wire-level plain
+ // BsonType.DateTime value - doing so would flip the in-memory type for every DateTime field
+ // and cause a later WriteTo to silently upgrade it to the new 10-byte layout on disk, even
+ // though nothing about an offset was actually recovered.
+ Span buffer = stackalloc byte[256];
+ var writer = new BsonSpanWriter(buffer, _keyMap);
+
+ var legacyUtc = new DateTime(2026, 6, 15, 18, 0, 0, DateTimeKind.Utc);
+
+ var sizePos = writer.BeginDocument();
+ writer.WriteDateTime("timestamp", legacyUtc);
+ writer.EndDocument(sizePos);
+
+ var documentBytes = buffer[..writer.Position];
+ var reader = new BsonSpanReader(documentBytes, _keys);
+
+ reader.ReadDocumentSize();
+ var type = reader.ReadBsonType();
+ reader.ReadElementHeader();
+
+ var value = BsonValue.ReadFrom(ref reader, type);
+
+ Assert.Equal(BsonType.DateTime, value.Type);
+ Assert.False(value.IsDateTimeOffset);
+
+ // Writing it back must reproduce the original 8-byte BsonType.DateTime wire format, not
+ // silently upgrade to the new 10-byte BsonType.DateTimeOffset layout.
+ Span rewriteBuffer = stackalloc byte[256];
+ var rewriter = new BsonSpanWriter(rewriteBuffer, _keyMap);
+ var rewriteSizePos = rewriter.BeginDocument();
+ value.WriteTo(ref rewriter, "timestamp");
+ rewriter.EndDocument(rewriteSizePos);
+
+ var rewrittenBytes = rewriteBuffer[..rewriter.Position];
+ Assert.True(documentBytes.SequenceEqual(rewrittenBytes));
+ }
+
[Fact]
public void WriteAndRead_NumericTypes()
{
diff --git a/tests/BLite.Tests/BsonValueTests.cs b/tests/BLite.Tests/BsonValueTests.cs
index d2576e92..62b8b983 100644
--- a/tests/BLite.Tests/BsonValueTests.cs
+++ b/tests/BLite.Tests/BsonValueTests.cs
@@ -202,7 +202,7 @@ public void FromDateTimeOffset_RoundTrips_ThroughUnixMs()
{
var dto = new DateTimeOffset(2025, 6, 15, 8, 30, 0, TimeSpan.Zero);
var v = BsonValue.FromDateTimeOffset(dto);
- Assert.Equal(BsonType.DateTime, v.Type);
+ Assert.Equal(BsonType.DateTimeOffset, v.Type);
Assert.Equal(dto, v.AsDateTimeOffset);
}