diff --git a/.github/workflows/sdk-compat-check.yml b/.github/workflows/sdk-compat-check.yml
index 9ccbe78..26c4929 100644
--- a/.github/workflows/sdk-compat-check.yml
+++ b/.github/workflows/sdk-compat-check.yml
@@ -63,10 +63,14 @@ jobs:
echo "## SDK Version: $VERSION" >> $GITHUB_STEP_SUMMARY
- name: Override Cosmos SDK version
+ shell: bash
run: |
- dotnet add src/CosmosDB.InMemoryEmulator/CosmosDB.InMemoryEmulator.csproj \
- package Microsoft.Azure.Cosmos \
- --version ${{ steps.sdk-version.outputs.version }}
+ VERSION="${{ steps.sdk-version.outputs.version }}"
+ # Update CosmosSDKVersion in the root Directory.Build.props — this single property
+ # drives the PackageReference, the HybridRow hint path, AND the
+ # COSMOS_SDK_PREVIEW_METHODS compile-time symbol across all projects in the repo.
+ sed -i "s|[^<]*|${VERSION}|" \
+ Directory.Build.props
- name: Build
run: dotnet build --configuration Release
diff --git a/Directory.Build.props b/Directory.Build.props
index 4be44e3..e9c1bda 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -8,4 +8,25 @@
true
+
+
+ 3.58.0
+
+
+
+
+ <_CosmosVersionBase>$([System.Text.RegularExpressions.Regex]::Replace($(CosmosSDKVersion), '-.*$', ''))
+
+
+
+
+ $(DefineConstants);COSMOS_SDK_PREVIEW_METHODS
+
+
diff --git a/src/CosmosDB.InMemoryEmulator/CosmosDB.InMemoryEmulator.csproj b/src/CosmosDB.InMemoryEmulator/CosmosDB.InMemoryEmulator.csproj
index ec105c7..7237df6 100644
--- a/src/CosmosDB.InMemoryEmulator/CosmosDB.InMemoryEmulator.csproj
+++ b/src/CosmosDB.InMemoryEmulator/CosmosDB.InMemoryEmulator.csproj
@@ -16,13 +16,9 @@
-
-
- 3.58.0
-
-
+
diff --git a/src/CosmosDB.InMemoryEmulator/InMemoryChangeFeedProcessor.cs b/src/CosmosDB.InMemoryEmulator/InMemoryChangeFeedProcessor.cs
index 090dbcc..3022da8 100644
--- a/src/CosmosDB.InMemoryEmulator/InMemoryChangeFeedProcessor.cs
+++ b/src/CosmosDB.InMemoryEmulator/InMemoryChangeFeedProcessor.cs
@@ -353,6 +353,75 @@ private async Task PollAsync(CancellationToken cancellationToken)
}
}
+#if COSMOS_SDK_PREVIEW_METHODS
+///
+/// A implementation that delivers all versions and deletes
+/// of every change (including intermediate updates) as objects,
+/// matching the behaviour of GetChangeFeedProcessorBuilderWithAllVersionsAndDeletes.
+///
+internal sealed class InMemoryAllVersionsChangeFeedProcessor : ChangeFeedProcessor
+{
+ private readonly InMemoryContainer _container;
+ private readonly Container.ChangeFeedHandler> _handler;
+ private CancellationTokenSource _cts;
+ private Task _pollingTask;
+ private long _checkpoint;
+
+ internal InMemoryAllVersionsChangeFeedProcessor(
+ InMemoryContainer container,
+ Container.ChangeFeedHandler> handler)
+ {
+ _container = container;
+ _handler = handler;
+ }
+
+ public override Task StartAsync()
+ {
+ _checkpoint = _container.GetChangeFeedCheckpoint();
+ _cts = new CancellationTokenSource();
+ _pollingTask = PollAsync(_cts.Token);
+ return Task.CompletedTask;
+ }
+
+ public override async Task StopAsync()
+ {
+ if (_cts != null)
+ {
+ await _cts.CancelAsync();
+ try { await _pollingTask; }
+ catch (OperationCanceledException) { }
+ _cts.Dispose();
+ _cts = null;
+ }
+ }
+
+ private async Task PollAsync(CancellationToken cancellationToken)
+ {
+ var context = new InMemoryChangeFeedProcessorContext();
+
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ await Task.Delay(TimeSpan.FromMilliseconds(50), cancellationToken);
+
+ var feedItems = _container.GetAllVersionsChangeFeedItems(_checkpoint);
+
+ if (feedItems.Count > 0)
+ {
+ try
+ {
+ await _handler(context, feedItems, cancellationToken);
+ _checkpoint += feedItems.Count;
+ }
+ catch (Exception) when (!cancellationToken.IsCancellationRequested)
+ {
+ // Handler threw — do not advance checkpoint so the same batch is redelivered
+ }
+ }
+ }
+ }
+}
+#endif
+
internal static class ChangeFeedProcessorBuilderFactory
{
private static readonly Assembly CosmosAssembly = typeof(Container).Assembly;
diff --git a/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs b/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs
index f259e0d..f7094d0 100644
--- a/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs
+++ b/src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs
@@ -3,6 +3,7 @@
using System.Globalization;
using System.Net;
using System.Reflection;
+using System.Runtime.CompilerServices;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Azure.Cosmos;
@@ -1967,6 +1968,73 @@ public FeedIterator GetChangeFeedIterator(long checkpoint)
return new InMemoryFeedIterator(items);
}
+#if COSMOS_SDK_PREVIEW_METHODS
+ ///
+ /// Returns all change feed entries starting from as
+ /// objects, preserving every version and delete.
+ /// Used by .
+ ///
+ internal IReadOnlyList> GetAllVersionsChangeFeedItems(long fromCheckpoint)
+ {
+ lock (_changeFeedLock)
+ {
+ var start = (int)Math.Max(0, fromCheckpoint);
+ var result = new List>(_changeFeed.Count - start);
+
+ for (var i = start; i < _changeFeed.Count; i++)
+ {
+ var entry = _changeFeed[i];
+ var opType = DetermineChangeFeedOperationType(i);
+ var metadata = CreateChangeFeedMetadata(opType, i, entry.Id);
+ var feedItem = new ChangeFeedItem { Metadata = metadata };
+ if (!entry.IsDelete)
+ feedItem.Current = JsonConvert.DeserializeObject(entry.Json, JsonSettings);
+ result.Add(feedItem);
+ }
+
+ return result;
+ }
+ }
+
+ private static readonly Type ChangeFeedMetadataType = typeof(ChangeFeedMetadata);
+ private static readonly PropertyInfo CfmOperationTypeProp = ChangeFeedMetadataType.GetProperty("OperationType");
+ private static readonly PropertyInfo CfmLsnProp = ChangeFeedMetadataType.GetProperty("Lsn");
+ private static readonly PropertyInfo CfmIdProp = ChangeFeedMetadataType.GetProperty("Id");
+
+ private static ChangeFeedMetadata CreateChangeFeedMetadata(
+ ChangeFeedOperationType operationType, long lsn, string id)
+ {
+ var metadata = (ChangeFeedMetadata)RuntimeHelpers.GetUninitializedObject(ChangeFeedMetadataType);
+ CfmOperationTypeProp?.SetMethod?.Invoke(metadata, new object[] { operationType });
+ CfmLsnProp?.SetMethod?.Invoke(metadata, new object[] { lsn });
+ CfmIdProp?.SetMethod?.Invoke(metadata, new object[] { id });
+ return metadata;
+ }
+
+ ///
+ /// Determines whether the change feed entry at position represents
+ /// a Create, Replace, or Delete by scanning prior entries for the same item.
+ /// Must be called while is held.
+ ///
+ private ChangeFeedOperationType DetermineChangeFeedOperationType(int index)
+ {
+ var entry = _changeFeed[index];
+ if (entry.IsDelete)
+ return ChangeFeedOperationType.Delete;
+
+ // Scan backwards to find the most recent prior entry for this (Id, PartitionKey)
+ for (var j = index - 1; j >= 0; j--)
+ {
+ var prev = _changeFeed[j];
+ if (prev.Id == entry.Id && prev.PartitionKey == entry.PartitionKey)
+ return prev.IsDelete ? ChangeFeedOperationType.Create : ChangeFeedOperationType.Replace;
+ }
+
+ // No prior entry → this is the first occurrence (Create)
+ return ChangeFeedOperationType.Create;
+ }
+#endif
+
public override FeedIterator GetChangeFeedStreamIterator(
ChangeFeedStartFrom changeFeedStartFrom, ChangeFeedMode changeFeedMode,
ChangeFeedRequestOptions changeFeedRequestOptions = null)
@@ -2229,6 +2297,13 @@ public override ChangeFeedProcessorBuilder GetChangeFeedEstimatorBuilder(
TimeSpan? estimationPeriod = null)
=> ChangeFeedProcessorBuilderFactory.Create(processorName, new NoOpChangeFeedProcessor());
+#if COSMOS_SDK_PREVIEW_METHODS
+ public override ChangeFeedProcessorBuilder GetChangeFeedProcessorBuilderWithAllVersionsAndDeletes(
+ string processorName, Container.ChangeFeedHandler> onChangesDelegate)
+ => ChangeFeedProcessorBuilderFactory.Create(processorName,
+ new InMemoryAllVersionsChangeFeedProcessor(this, onChangesDelegate));
+#endif
+
// ═══════════════════════════════════════════════════════════════════════════
// Feed Ranges
// ═══════════════════════════════════════════════════════════════════════════
@@ -2249,6 +2324,50 @@ public override Task> GetFeedRangesAsync(CancellationTo
return Task.FromResult>(ranges);
}
+#if COSMOS_SDK_PREVIEW_METHODS
+ public override Task> GetPartitionKeyRangesAsync(
+ FeedRange feedRange, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var count = Math.Max(1, FeedRangeCount);
+ var step = 0x1_0000_0000L / count;
+ var (queryMin, queryMax) = ParseFeedRangeBoundaries(feedRange);
+
+ var rangeIds = new List();
+ for (var i = 0; i < count; i++)
+ {
+ if (queryMin == null || queryMax == null)
+ {
+ rangeIds.Add(i.ToString());
+ continue;
+ }
+
+ var rangeMin = (uint)(i * step);
+ var rangeMax = i == count - 1 ? uint.MaxValue : (uint)((i + 1) * step) - 1u;
+
+ // Overlap check: intervals [rangeMin, rangeMax] and [queryMin, queryMax]
+ if (rangeMin <= queryMax.Value && queryMin.Value <= rangeMax)
+ rangeIds.Add(i.ToString());
+ }
+
+ return Task.FromResult>(rangeIds);
+ }
+
+ public override Task SemanticRerankAsync(
+ string rerankContext,
+ IEnumerable documents,
+ IDictionary options,
+ CancellationToken cancellationToken = default)
+ => throw new InMemoryCosmosException(
+ "SemanticRerankAsync is not supported by the in-memory emulator. " +
+ "This API requires a live Cosmos DB endpoint with semantic ranking capabilities.",
+ HttpStatusCode.BadRequest,
+ 0,
+ Guid.NewGuid().ToString(),
+ SyntheticRequestCharge);
+#endif
+
// ═══════════════════════════════════════════════════════════════════════════
// Container management
// ═══════════════════════════════════════════════════════════════════════════
diff --git a/src/CosmosDB.InMemoryEmulator/PartitionKeyCapturingContainer.cs b/src/CosmosDB.InMemoryEmulator/PartitionKeyCapturingContainer.cs
index 76f54a2..5c1bcaa 100644
--- a/src/CosmosDB.InMemoryEmulator/PartitionKeyCapturingContainer.cs
+++ b/src/CosmosDB.InMemoryEmulator/PartitionKeyCapturingContainer.cs
@@ -241,11 +241,28 @@ public override ChangeFeedProcessorBuilder GetChangeFeedEstimatorBuilder(
string processorName, ChangesEstimationHandler estimationDelegate, TimeSpan? estimationPeriod = null)
=> _inner.GetChangeFeedEstimatorBuilder(processorName, estimationDelegate, estimationPeriod);
+#if COSMOS_SDK_PREVIEW_METHODS
+ public override ChangeFeedProcessorBuilder GetChangeFeedProcessorBuilderWithAllVersionsAndDeletes(
+ string processorName, ChangeFeedHandler> onChangesDelegate)
+ => _inner.GetChangeFeedProcessorBuilderWithAllVersionsAndDeletes(processorName, onChangesDelegate);
+#endif
+
// ── Feed ranges ─────────────────────────────────────────────────
public override Task> GetFeedRangesAsync(CancellationToken cancellationToken = default)
=> _inner.GetFeedRangesAsync(cancellationToken);
+#if COSMOS_SDK_PREVIEW_METHODS
+ public override Task> GetPartitionKeyRangesAsync(
+ FeedRange feedRange, CancellationToken cancellationToken = default)
+ => _inner.GetPartitionKeyRangesAsync(feedRange, cancellationToken);
+
+ public override Task SemanticRerankAsync(
+ string rerankContext, IEnumerable documents,
+ IDictionary options, CancellationToken cancellationToken = default)
+ => _inner.SemanticRerankAsync(rerankContext, documents, options, cancellationToken);
+#endif
+
// ── Batch ───────────────────────────────────────────────────────
public override TransactionalBatch CreateTransactionalBatch(PartitionKey partitionKey)
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index 0d197fa..ce85e67 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -5,7 +5,7 @@
true
- 4.0.5
+ 4.0.7
lemonlion
Copyright (c) 2026 lemonlion
MIT
diff --git a/tests/CosmosDB.InMemoryEmulator.Tests.Unit/PreviewSdkContainerMethodTests.cs b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/PreviewSdkContainerMethodTests.cs
new file mode 100644
index 0000000..a1aad09
--- /dev/null
+++ b/tests/CosmosDB.InMemoryEmulator.Tests.Unit/PreviewSdkContainerMethodTests.cs
@@ -0,0 +1,269 @@
+using AwesomeAssertions;
+using Microsoft.Azure.Cosmos;
+using Xunit;
+
+namespace CosmosDB.InMemoryEmulator.Tests;
+
+///
+/// Tests for Container methods that became public abstract in 3.59.0-preview.0:
+///
+///
+///
+///
+///
+/// Tests that require types that were internal in 3.58.x are compiled only when targeting
+/// 3.59.0 or later (guarded by the COSMOS_SDK_PREVIEW_METHODS symbol).
+///
+public class PreviewSdkContainerMethodTests
+{
+ private readonly InMemoryContainer _container = new("test-container", "/partitionKey");
+
+#if COSMOS_SDK_PREVIEW_METHODS
+
+ // ── GetChangeFeedProcessorBuilderWithAllVersionsAndDeletes ────────────────
+
+ [Fact]
+ public async Task AllVersionsProcessor_Create_ReportsCreateOperationType()
+ {
+ var received = new List>();
+ var tcs = new TaskCompletionSource();
+
+ var processor = _container
+ .GetChangeFeedProcessorBuilderWithAllVersionsAndDeletes(
+ "avd-processor",
+ (_, changes, _) =>
+ {
+ lock (received) received.AddRange(changes);
+ tcs.TrySetResult(true);
+ return Task.CompletedTask;
+ })
+ .WithInstanceName("i1")
+ .WithInMemoryLeaseContainer()
+ .Build();
+
+ await processor.StartAsync();
+
+ await _container.CreateItemAsync(
+ new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" },
+ new PartitionKey("pk1"));
+
+ await Task.WhenAny(tcs.Task, Task.Delay(5_000));
+ await processor.StopAsync();
+
+ received.Should().ContainSingle();
+ received[0].Metadata.OperationType.Should().Be(ChangeFeedOperationType.Create);
+ received[0].Current.Should().NotBeNull();
+ received[0].Current!.Name.Should().Be("Alice");
+ }
+
+ [Fact]
+ public async Task AllVersionsProcessor_Upsert_ReportsReplaceOperationType()
+ {
+ await _container.CreateItemAsync(
+ new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V1" },
+ new PartitionKey("pk1"));
+
+ var received = new List>();
+ var tcs = new TaskCompletionSource();
+
+ var processor = _container
+ .GetChangeFeedProcessorBuilderWithAllVersionsAndDeletes(
+ "avd-processor",
+ (_, changes, _) =>
+ {
+ lock (received) received.AddRange(changes);
+ tcs.TrySetResult(true);
+ return Task.CompletedTask;
+ })
+ .WithInstanceName("i1")
+ .WithInMemoryLeaseContainer()
+ .Build();
+
+ await processor.StartAsync();
+
+ await _container.UpsertItemAsync(
+ new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V2" },
+ new PartitionKey("pk1"));
+
+ await Task.WhenAny(tcs.Task, Task.Delay(5_000));
+ await processor.StopAsync();
+
+ received.Should().ContainSingle();
+ received[0].Metadata.OperationType.Should().Be(ChangeFeedOperationType.Replace);
+ }
+
+ [Fact]
+ public async Task AllVersionsProcessor_Delete_ReportsDeleteOperationType()
+ {
+ await _container.CreateItemAsync(
+ new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Alice" },
+ new PartitionKey("pk1"));
+
+ var received = new List>();
+ var tcs = new TaskCompletionSource();
+
+ var processor = _container
+ .GetChangeFeedProcessorBuilderWithAllVersionsAndDeletes(
+ "avd-processor",
+ (_, changes, _) =>
+ {
+ lock (received) received.AddRange(changes);
+ tcs.TrySetResult(true);
+ return Task.CompletedTask;
+ })
+ .WithInstanceName("i1")
+ .WithInMemoryLeaseContainer()
+ .Build();
+
+ await processor.StartAsync();
+
+ await _container.DeleteItemAsync("1", new PartitionKey("pk1"));
+
+ await Task.WhenAny(tcs.Task, Task.Delay(5_000));
+ await processor.StopAsync();
+
+ received.Should().ContainSingle();
+ received[0].Metadata.OperationType.Should().Be(ChangeFeedOperationType.Delete);
+ }
+
+ [Fact]
+ public async Task AllVersionsProcessor_DeliverAllVersionsWithoutDeduplication()
+ {
+ var received = new List>();
+ var targetCount = 3;
+ var tcs = new TaskCompletionSource();
+
+ var processor = _container
+ .GetChangeFeedProcessorBuilderWithAllVersionsAndDeletes(
+ "avd-processor",
+ (_, changes, _) =>
+ {
+ lock (received)
+ {
+ received.AddRange(changes);
+ if (received.Count >= targetCount) tcs.TrySetResult(true);
+ }
+ return Task.CompletedTask;
+ })
+ .WithInstanceName("i1")
+ .WithInMemoryLeaseContainer()
+ .Build();
+
+ await processor.StartAsync();
+
+ await _container.CreateItemAsync(
+ new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V1" },
+ new PartitionKey("pk1"));
+ await _container.UpsertItemAsync(
+ new TestDocument { Id = "1", PartitionKey = "pk1", Name = "V2" },
+ new PartitionKey("pk1"));
+ await _container.DeleteItemAsync("1", new PartitionKey("pk1"));
+
+ await Task.WhenAny(tcs.Task, Task.Delay(5_000));
+ await processor.StopAsync();
+
+ received.Should().HaveCount(3);
+ received[0].Metadata.OperationType.Should().Be(ChangeFeedOperationType.Create);
+ received[1].Metadata.OperationType.Should().Be(ChangeFeedOperationType.Replace);
+ received[2].Metadata.OperationType.Should().Be(ChangeFeedOperationType.Delete);
+ }
+
+ [Fact]
+ public async Task AllVersionsProcessor_RecreatedItem_ReportsCreateAfterDelete()
+ {
+ // Create, delete, then re-create the same item id
+ await _container.CreateItemAsync(
+ new TestDocument { Id = "1", PartitionKey = "pk1", Name = "First" },
+ new PartitionKey("pk1"));
+ await _container.DeleteItemAsync("1", new PartitionKey("pk1"));
+
+ var received = new List>();
+ var tcs = new TaskCompletionSource();
+
+ var processor = _container
+ .GetChangeFeedProcessorBuilderWithAllVersionsAndDeletes(
+ "avd-processor",
+ (_, changes, _) =>
+ {
+ lock (received)
+ {
+ received.AddRange(changes);
+ if (received.Count >= 1) tcs.TrySetResult(true);
+ }
+ return Task.CompletedTask;
+ })
+ .WithInstanceName("i1")
+ .WithInMemoryLeaseContainer()
+ .Build();
+
+ await processor.StartAsync();
+
+ await _container.CreateItemAsync(
+ new TestDocument { Id = "1", PartitionKey = "pk1", Name = "Recreated" },
+ new PartitionKey("pk1"));
+
+ await Task.WhenAny(tcs.Task, Task.Delay(5_000));
+ await processor.StopAsync();
+
+ received.Should().ContainSingle();
+ received[0].Metadata.OperationType.Should().Be(ChangeFeedOperationType.Create,
+ "re-creating an item after a delete should report Create, not Replace");
+ }
+
+ // ── GetPartitionKeyRangesAsync ────────────────────────────────────────────
+
+ [Fact]
+ public async Task GetPartitionKeyRangesAsync_SingleRange_ReturnsZero()
+ {
+ var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 1 };
+ var feedRanges = await container.GetFeedRangesAsync();
+
+ var rangeIds = (await container.GetPartitionKeyRangesAsync(feedRanges[0])).ToList();
+
+ rangeIds.Should().ContainSingle().Which.Should().Be("0");
+ }
+
+ [Fact]
+ public async Task GetPartitionKeyRangesAsync_MultipleRanges_ReturnsMatchingIds()
+ {
+ var container = new InMemoryContainer("test", "/partitionKey") { FeedRangeCount = 4 };
+ var feedRanges = await container.GetFeedRangesAsync();
+
+ var allRangeIds = new List();
+ foreach (var fr in feedRanges)
+ {
+ var ids = await container.GetPartitionKeyRangesAsync(fr);
+ allRangeIds.AddRange(ids);
+ }
+
+ // Each feed range should map to exactly one partition key range
+ allRangeIds.Should().HaveCount(4);
+ allRangeIds.Should().BeEquivalentTo(new[] { "0", "1", "2", "3" });
+ }
+
+ // ── SemanticRerankAsync ────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task SemanticRerankAsync_ThrowsCosmosException()
+ {
+ var act = async () => await _container.SemanticRerankAsync(
+ "my context", new[] { "doc1", "doc2" }, null);
+
+ // The in-memory emulator throws CosmosException (BadRequest) to match the exception
+ // type that the real service returns, so callers catching CosmosException work correctly.
+ await act.Should().ThrowAsync()
+ .WithMessage("*SemanticRerankAsync*");
+ }
+
+#else
+ [Fact]
+ public void PreviewMethods_NotAvailableIn358_SkipCoverage()
+ {
+ // The three abstract methods (GetChangeFeedProcessorBuilderWithAllVersionsAndDeletes,
+ // GetPartitionKeyRangesAsync, SemanticRerankAsync) were internal in SDK 3.58.x.
+ // Full test coverage is enabled by the COSMOS_SDK_PREVIEW_METHODS symbol when building
+ // against 3.59.0 or later.
+ Assert.True(true, "Placeholder — preview method tests compiled out for 3.58.x.");
+ }
+#endif
+}