Skip to content

Kontent.Ai.Sync 2.0.0-rc.1

Pre-release
Pre-release

Choose a tag to compare

@net-release-bot net-release-bot released this 07 Aug 15:51

Targets .NET 10, moving from net8.0 to net10.0, and Refit's transport is upgraded across four major
versions. The SDK also becomes a single package: Kontent.Ai.Sync.Abstractions is folded in.

The API changes too. Paging through the sync feed becomes a stream you enumerate, terminating on the
signal the API actually sends rather than on an inferred page size; the result contract splits so that
initialization stops pretending to return content; and disposal moves off the client interface onto the
client that owns resources. Most consumers touch one loop and nothing else.

See the 1.0 → 2.0 upgrade guide for the migration, change by change.

Breaking changes

  • Kontent.Ai.Sync.Abstractions is gone; everything it held now ships in Kontent.Ai.Sync, in the Kontent.Ai.Sync namespace. The split existed so contracts could be referenced without the client, and nothing ever did that — the package's only consumer was Kontent.Ai.Sync itself. Meanwhile a third of it was not abstract at all (SyncOptions, ApiMode, SyncOptionsExtensions), and keeping the contracts in a separate assembly is what forced every response model to exist three times: a public interface, an internal record, and an explicit reimplementation. The Management SDK reached the same conclusion and never split.

    Drop the package reference and change one using:

    // Before
    using Kontent.Ai.Sync;
    using Kontent.Ai.Sync.Abstractions;
    
    // After
    using Kontent.Ai.Sync;

    Every type keeps its name and its members; only the namespace and the assembly change. There is no compatibility shim: a stale Kontent.Ai.Sync.Abstractions reference fails to compile rather than resolving to a package that will never be updated again. That package is delisted at 2.0.

  • The four delta entry types collapse into one generic SyncChange<TData>, and Data is now typed. ISyncItem, ISyncType, ISyncLanguage and ISyncTaxonomy declared exactly the same two members and were backed by four identical records — while the thing that genuinely differs between them, the payload, was hidden behind object?. The split was in the wrong place: every delta the API returns shares one envelope, and none of them share a payload.

    // Before — same shape four times, payload opaque
    foreach (ISyncItem item in page.Value.Items)
    {
        var data = item.Data;               // object?, a JsonElement at runtime
    }
    
    // After
    foreach (SyncChange<SyncItemData> item in page.Value.Items)
    {
        var codename = item.Data?.System.Codename;
        var when     = item.Timestamp;
    }

    SyncItemData, SyncTypeData, SyncLanguageData and SyncTaxonomyData model each system object as the API documents it. They are deliberately not one type: a content item carries collection, language, type and workflow state, while a language carries three properties and no last_modified at all. SyncTypeData and SyncTaxonomyData happen to match today and are still separate, because the API may extend either alone.

    The deprecated sitemap_locations array is not modelled. It is scheduled for removal, and leaving it out means that removal changes nothing here.

  • net8.0net10.0. There is no multi-targeting, so a project on .NET 8 cannot install this release at all — restore fails with NU1202: Package Kontent.Ai.Sync is not compatible with net8.0. Move to .NET 10 first.

  • InitializeSyncAsync returns ISyncResult instead of ISyncResult<ISyncInitResponse>, and ISyncInitResponse is removed. Initialization establishes a starting point rather than returning content — the useful output has always been the token, on SyncToken. ISyncInitResponse was an interface with no members, so Value was an object you could hold but never read. ISyncResult is new and non-generic, and ISyncResult<T> now derives from it, adding only Value; this mirrors IManagementResult / IManagementResult<T> in the Management SDK. Every other member is unchanged and still reachable on both.

    // Before
    ISyncResult<ISyncInitResponse> init = await syncClient.InitializeSyncAsync();
    
    // After — or simply var, as every example in the README uses
    ISyncResult init = await syncClient.InitializeSyncAsync();
    await SaveTokenAsync(init.SyncToken);   // unchanged

    GetDeltaAsync and EnumerateDeltaAsync are untouched, including page.Value.Items. Initialization also no longer deserializes a response body: success now depends on the status code and the continuation token alone, so an unreadable body on an endpoint whose body is irrelevant can no longer fail the call.

  • The client interfaces no longer carry IDisposable / IAsyncDisposable; the concrete clients do. Disposal exists for one situation - a client built outside a container, which owns its own transport and must release it. Putting it on the interface meant every consumer holding ISyncClient was offered a Dispose() that, on the container path, released nothing and must not be called: the container owns that lifetime. SyncClientBuilder.Build() now returns the concrete SyncClient, which is IDisposable and IAsyncDisposable, so disposal stays available exactly where it means something.

    await using var client = SyncClientBuilder…Build(); is unchanged, and so is every DI usage. The only code that breaks widened the builder result to the interface and then disposed it:

    // Before - no longer compiles, because the interface has no Dispose
    ISyncClient client = SyncClientBuilderBuild();
    client.Dispose();
    
    // After - keep the concrete type, or just use var
    var client = SyncClientBuilderBuild();
    client.Dispose();

    Container-resolved clients are still disposed by the container, which checks the runtime type rather than the registered service type - so nothing changes there.

  • SyncClientBuilder.ConfigureServices is removed, replaced by WithResilience. Building a client outside dependency injection no longer stands up a private service container: the client constructs the handler chain directly and owns the resulting HttpClient, matching how ManagementClientBuilder already worked. ConfigureServices existed only to reach into that container and has nothing left to configure. Replacing the resilience pipeline was the one thing it was realistically used for, so that is now a first-class WithResilience(...), mirroring the DI overloads and the Management builder. Everything else is unchanged.

    This also removes a layer that existed only to make disposal work. Previously Build() returned a wrapper whose sole job was to delegate every method and dispose the container behind it; now the client itself owns its HttpClient, so disposing it releases exactly what it created. Clients resolved from a container are unaffected: the container owns their transport, and disposing one releases nothing.

  • GetAllDeltaAsync is replaced by EnumerateDeltaAsync, which returns IAsyncEnumerable<ISyncResult<ISyncDeltaResponse>>. The old helper decided it had caught up when no collection in a response had reached 100 entries, a threshold published as SyncConstants.MaxItemsPerEntityType. The Sync API defines completion differently — as an empty response — so the walk ended one request before the API had confirmed the feed was drained, on a condition inferred from a page size rather than read from the signal the API sends. The replacement stops on the empty response, and streams pages instead of buffering every one in memory. Bounding the walk moves to the caller, where Take or a break replaces maxPages.

    // Before
    var all = await syncClient.GetAllDeltaAsync(syncToken, maxPages: 10);
    foreach (var page in all.Responses) { /* ... */ }
    
    // After
    await foreach (var page in syncClient.EnumerateDeltaAsync(syncToken).Take(10))
    {
        if (!page.IsSuccess) break;
        /* page.Value */
    }
  • ISyncResult<T>.SyncToken is no longer nullable, and a successful response without an X-Continuation header now throws. The API issues a fresh token with every initialization and every delta, and it is the only way to make the next request — so a successful response without one leaves a caller holding data it can never continue from. That is now refused where the response is mapped, which covers InitializeSyncAsync, GetDeltaAsync and EnumerateDeltaAsync alike, with an InvalidOperationException naming the request. In exchange SyncToken is declared string rather than string?, matching Value: both are meaningful only when IsSuccess is true. Code that wrote result.SyncToken ?? previous can drop the fallback — it was dead once the guarantee existed. Nothing changes for callers that only read the token after checking IsSuccess.

  • ISyncResult<T>.HasMoreChanges, SyncConstants and ISyncAllDeltaResult are removed. All three existed only to support the threshold above. With completion defined by the API's empty response, the sequence simply ends: there is no "are there more" flag to read, and no client-side page-size constant to keep in step with the server. Carry SyncToken from the last yielded result; when nothing is yielded, the token you passed in is still current.

  • The configureRefit parameter is gone from all three AddSyncClient overloads. The hook exposed the transport library's settings object, but everything reachable through it was load-bearing rather than configurable — the parameter-key formatter matches the API's casing, and the serializer options carry the converters the wire format requires. Overriding them broke requests silently. Delete the argument; the SDK's own tests only ever used it to assert the callback fired.

Changed

  • Registering from IConfiguration can now customize the HTTP client and the resilience pipeline. The configuration-based AddSyncClient overloads took no configureHttpClient / configureResilience, so binding options from configuration and replacing the retry pipeline were mutually exclusive. The workaround — binding by hand inside an Action<SyncOptions> — compiles and looks equivalent, but registers no change token, so IOptionsMonitor silently stops reloading. Both hooks are now available on every configuration overload, alongside the ones that already had them.
  • AddSyncClient gained the overloads its sibling SDKs already had, so the three register a client the same way: options configured with access to the IServiceProvider, in both named and unnamed form. Nothing was removed, and existing calls are unaffected — this closes gaps rather than reshaping the surface.
  • SyncOptions.DefaultConfigurationSectionName exposes the section name the configuration overloads bind by default, matching ManagementOptions. Design-time tools that resolve the SDK's configuration from the same sources can probe it instead of hard-coding the string.
  • Cancellation now throws; other transport failures are results. Refit's upgrade changed the
    contract: exceptions raised in the HTTP pipeline are captured into the response rather than thrown.
    A network failure, DNS failure or resilience-pipeline rejection is therefore an unsuccessful result
    carrying the exception, consistent with how every other failure in this SDK is reported. Cancellation
    is the exception to that: when the caller's token fires, the OperationCanceledException is rethrown,
    so Task.IsCanceled, Task.WhenAll and cancellation handlers behave as they do everywhere else in
    .NET. Previously all of these threw. An expired HttpClient.Timeout is not cancellation, even
    though .NET surfaces it as a TaskCanceledException: the request was sent, so it is reported as a
    failed result like any other transport failure.
  • Transport failures report status 0. ISyncResult now carries (HttpStatusCode)0 for that case rather than an invented code. Responses that did arrive are unaffected.

Fixed

  • Every delta now carries its timestamp. The API marks it required on all four delta objects and the SDK dropped it entirely, so there was no way to tell when a change happened — only that it had. It is exposed as SyncChange<TData>.Timestamp, a DateTime in UTC — matching how every other Kontent.ai SDK types a server-sent timestamp.
  • The configured retry pipeline can now run to completion. HttpClient's 100-second default bounds the whole call, retries and backoff included, and nothing raised it — so a pipeline allowed four 30-second attempts plus exponential backoff was silently cut off partway through the last one. The SDK's resilience pipeline already bounds each attempt, so it now owns timing outright and the transport-level ceiling is removed. Requests still stop when your CancellationToken fires.
  • Long-running applications pick up DNS changes instead of pinning the address resolved at startup. The registered client is a singleton and takes its HttpClient from IHttpClientFactory once, so the handler chain it holds was never rotated — the factory only hands a fresh chain to a new CreateClient call. Connections now recycle every two minutes, matching the factory's own default handler lifetime. A sync walk is exactly the kind of process that stays up long enough for this to matter. Configuring your own primary handler via configureHttpClient still overrides this, as before.
  • Responses are now disposed once mapped, instead of every request leaking one until finalization. Each call held its HttpResponseMessage open for the garbage collector to reclaim, which on a sync walk means one per page. Nothing the result carries is affected — Refit buffers the content, and header collections outlive disposal.
  • A failure resolving the X-KC-SOURCE header no longer breaks every later request. The value is cached in a Lazy<string?>, and the resolution walks the call stack to attribute the calling package. An exception thrown during that walk was cached alongside the value and rethrown on every subsequent request for the lifetime of the process. Resolution failures are now contained and the header simply omitted, which was the intent.
  • An assembly with no informational version reports 0.0.0 in tracking headers rather than an empty version. The build-metadata stripping ran after the fallback rather than before it, so a blank version survived as an empty string and travelled into the header. Only reachable for an assembly built without version attributes, which is not the case for anything this SDK ships.

Dependencies

Shipped floors on Kontent.Ai.Sync moved up, all .NET 10 aligned:

  • Microsoft.Extensions.* (Configuration, .Binder, Logging.Abstractions, Options, .ConfigurationExtensions, .DataAnnotations) 9.0.1510.0.10.
  • Microsoft.Extensions.Http.Resilience 9.6.010.8.0.
  • Refit and Refit.HttpClientFactory 10.2.014.0.1.

Internal

Refit 14 builds request logic at compile time rather than by reflection. ISyncApi generates completely and gained that with no changes.

Installation

dotnet add package Kontent.Ai.Sync --prerelease

Full changelog: src/sync/CHANGELOG.md