Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions contracts/cove.openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -33169,6 +33169,7 @@
},
"SystemStatus": {
"required": [
"contractVersion",
"databasePath",
"version"
],
Expand All @@ -33177,6 +33178,9 @@
"version": {
"type": "string"
},
"contractVersion": {
"type": "string"
},
"appDir": {
"type": "string"
},
Expand Down
86 changes: 86 additions & 0 deletions docs/contributing/versioning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Extension versioning and deprecation policy

This document is the authoritative reference for how Cove versions the contracts that extensions
depend on, and how compatibility is negotiated and eventually deprecated. It applies to extension
authors and to anyone changing the host-facing contracts. The goal is simple: an extension should
know, from versions alone, whether it will load and run against a given Cove release.

## The two version axes

Cove exposes two independent contracts to extensions, and they are versioned separately.

- **Host contract version.** The semver `major.minor.patch` value reported by `CoveVersion`
(baked into the build from the release tag). This is the single source of truth for backend
compatibility. An extension declares the oldest host it supports through its `min-host-version`
requirement, and Cove reports the current value on `GET /api/system/status` (the
`contractVersion` field) so tooling can compare the two without guessing.
- **Frontend runtime contract version.** The `v1` / `v2` string that identifies the shape of the
browser runtime import map (the set of shared modules the host provides to extension bundles,
such as the UI framework and data-fetching client). A frontend bundle targets one runtime
contract version; the host serves the import map for the versions it still supports.

A backend-only extension pins only the host contract version. An extension that ships a frontend
bundle pins both: the host contract version for its server-side code and the runtime contract
version for its bundle.

## `@cove/types` versioning

`@cove/types` is generated only — it is produced from the host's DTOs and enums and is never
hand-edited. It tracks the host contract version: a host release `x.y.z` publishes
`@cove/types@x.y.*`, so choosing the types package for a host version is unambiguous.

- **Additive changes are non-breaking.** A new optional DTO field or a new enum member is a minor
change; existing extensions keep compiling and running.
- **Breaking changes bump the version accordingly.** Removing or renaming a field, changing a
field's type, or removing an enum member is a breaking change and is reflected in the semver
bump of both the host contract version and the matching `@cove/types` release.

Extensions should depend on the `@cove/types` line that matches the oldest host they support, and
rely on additive-only changes within that line.

## `@cove/extension-sdk` versioning

`@cove/extension-sdk` is the author-facing SDK. Its public surface is gated in continuous
integration (the exported API is extracted and compared, so an unintended change to the public
surface fails the build). The SDK's major and minor versions track the host contract version, and
each SDK release depends on the matching `@cove/types` release.

- The SDK never widens its public API silently; any addition or removal is an intentional,
reviewed version change.
- Pinning an SDK version therefore pins a known host-contract baseline and a known `@cove/types`
baseline together.

## Support window and deprecation

Cove supports the **current and the immediately previous** frontend runtime contract version. When
a runtime contract version is scheduled for removal, it first enters a deprecation window:

- The version to be removed is announced as deprecated for at least **one minor release** before it
is dropped. During that window it continues to load.
- While a version is deprecated (that is, it is the previous supported version, `N-1`), the host
still serves it, and negotiation emits a **warning** so authors have time to migrate.
- Once a version falls below the minimum supported runtime contract version, negotiation
**rejects** bundles that target it; they no longer load.

The host contract version follows ordinary semver expectations: additive backend changes are
minor, breaking backend changes are major, and an extension's `min-host-version` is honored against
the reported contract version.

## How negotiation surfaces to authors

Compatibility is checked at two points, and both quote the host version and the required floor so
the fix is obvious:

- **Install time.** Installing an extension whose `min-host-version` is above the host's contract
version is refused with a clear error stating the required minimum and the current host version.
- **Load time.** On a released host, an installed extension whose `min-host-version` exceeds the
host contract version is disabled at startup with an actionable message naming both the host
version and the required floor, rather than being allowed to initialize in an unsupported state.
On a development build of the host, the same mismatch is reported as a warning only and the
extension still loads, so work against a not-yet-released host is never blocked. An unparseable
requirement is treated as unsatisfied and never crashes the host.

In short: match your `@cove/types` and `@cove/extension-sdk` versions to the oldest host you intend
to support, set `min-host-version` to that host's contract version, and target a currently
supported frontend runtime contract version. Negotiation will then either load your extension or
tell you exactly which version to change.
1 change: 1 addition & 0 deletions sdk/frontend/dist/extension-sdk.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3762,6 +3762,7 @@ declare interface components {
/** @default false */
authEnabled?: boolean;
configFile?: string;
contractVersion: string;
databasePath: string;
/** @default false */
migrationRequired?: boolean;
Expand Down
1 change: 1 addition & 0 deletions sdk/types/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20497,6 +20497,7 @@ export interface components {
/** @default false */
authEnabled?: boolean;
configFile?: string;
contractVersion: string;
databasePath: string;
/** @default false */
migrationRequired?: boolean;
Expand Down
1 change: 1 addition & 0 deletions src/Cove.Api/Controllers/SystemController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ public async Task<ActionResult<SystemStatusDto>> GetStatus()

return Ok(new SystemStatusDto(
Version: Cove.Core.Common.CoveVersion.Display,
ContractVersion: Cove.Core.Common.CoveVersion.Numeric,
AppDir: canSeeSensitivePaths ? AppContext.BaseDirectory : null,
ConfigFile: canSeeSensitivePaths ? configService.ConfigPath : null,
DatabasePath: "PostgreSQL",
Expand Down
6 changes: 6 additions & 0 deletions src/Cove.Api/Cove.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,12 @@
<ProjectReference Include="..\Cove.Data\Cove.Data.csproj" />
<ProjectReference Include="..\Cove.Plugins\Cove.Plugins.csproj" />
<ProjectReference Include="..\Cove.Sdk\Cove.Sdk.csproj" />
<!-- Build-time source generator that emits the host-service DI forwarding (AddCoveHostServices).
Referenced as an analyzer so the generator assembly is never emitted into this project's
output and is not propagated to downstream (extension) builds. -->
<ProjectReference Include="..\..\tools\host-services-gen\host-services-gen.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>

</Project>
13 changes: 8 additions & 5 deletions src/Cove.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
using Serilog;
using Serilog.Core;
using Serilog.Events;
using Cove.Api.HostServices;
using Cove.Api.Hubs;
using Cove.Api.Services;
using Cove.Core.Common;
Expand Down Expand Up @@ -358,13 +359,14 @@ LIMIT 1
AutomaticDecompression = System.Net.DecompressionMethods.All,
});
builder.Services.AddHttpClient<MetadataServerService>();
// Runtime extensions can bind only Cove.Core types, so surface the Cove.Api metadata-server client
// through its Cove.Core interface (as IReferencePerformerImporter below does).
builder.Services.AddTransient<IMetadataServerService>(sp => sp.GetRequiredService<MetadataServerService>());
// Lets extensions (AI.Faces) enrich a newly-created performer from a configured metadata server
// when a reference/SAIE match is accepted. Singleton so it is shared into extension containers; it
// opens its own scope per call.
builder.Services.AddSingleton<IReferencePerformerImporter, ReferencePerformerImporter>();
builder.Services.AddSingleton<ReferencePerformerImporter>();
// Surface the host services above to extensions through their Cove.Core interfaces. Runtime
// extensions can bind only Cove.Core types, so each [ExposeToExtensions]-marked concrete is
// forwarded to its interface here.
builder.Services.AddCoveHostServices();

// Extension system
var extensionsDataDir = CoveDefaultPaths.GetDataSubdirectory("extensions");
Expand All @@ -374,7 +376,8 @@ LIMIT 1
{
Configuration = builder.Configuration,
DataDirectory = extensionsDataDir,
CoveVersion = coveVersion
CoveVersion = coveVersion,
CoveVersionDisplay = Cove.Core.Common.CoveVersion.Display
};
var extensionManager = new ExtensionManager(extensionContext);
// Discover .NET plugin DLLs from extensions directory
Expand Down
2 changes: 2 additions & 0 deletions src/Cove.Api/Services/MetadataServerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using Microsoft.EntityFrameworkCore;
using Cove.Core.Contracts;
using Cove.Core.DTOs;
using Cove.Core.Entities;
using Cove.Core.Enums;
Expand All @@ -13,6 +14,7 @@

namespace Cove.Api.Services;

[ExposeToExtensions(typeof(IMetadataServerService))]
public class MetadataServerService : IMetadataServerService
{
private static readonly Regex LeadingVideoIndexRegex = new(@"^\s*(?:video\s+)?(?:\[\s*\d+\s*\]|\(\s*\d+\s*\)|\d+)\s*(?:[-–—:._)\]]\s*)+", RegexOptions.Compiled | RegexOptions.IgnoreCase);
Expand Down
2 changes: 2 additions & 0 deletions src/Cove.Api/Services/ReferencePerformerImporter.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Cove.Core.Contracts;
using Cove.Core.Interfaces;
using Cove.Core.Entities;
using Cove.Data;
Expand All @@ -15,6 +16,7 @@ namespace Cove.Api.Services;
/// endpoint, network error, deleted remote performer) is swallowed and reported as <c>false</c> so the
/// caller keeps the performer with just its recorded remote id.
/// </summary>
[ExposeToExtensions(typeof(IReferencePerformerImporter), Lifetime = ServiceForwardingLifetime.Singleton)]
public sealed class ReferencePerformerImporter(IServiceScopeFactory scopeFactory, ILogger<ReferencePerformerImporter>? logger = null)
: IReferencePerformerImporter
{
Expand Down
37 changes: 37 additions & 0 deletions src/Cove.Core/Contracts/ExposeToExtensionsAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
namespace Cove.Core.Contracts;

/// <summary>
/// The service lifetime used for the forwarding registration of a host service exposed to extensions.
/// Declared independently of any dependency-injection package so the contract stays in the
/// infrastructure-free core assembly.
/// </summary>
public enum ServiceForwardingLifetime
{
/// <summary>A new instance is provided for every request.</summary>
Transient,

/// <summary>A single instance is provided per scope.</summary>
Scoped,

/// <summary>A single instance is shared for the lifetime of the application.</summary>
Singleton,
}

/// <summary>
/// Marks a host service implementation as the backing type for an extension-facing interface. The build
/// emits a forwarding service registration for the marked type and fails compilation when the marked type
/// does not implement <see cref="InterfaceType"/>. Applying this attribute replaces hand-written forwarding
/// registrations: the interface remains the single type extensions bind against, and the concrete type
/// stays private to the host.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class ExposeToExtensionsAttribute(Type interfaceType) : Attribute
{
/// <summary>The extension-facing interface the marked type is exposed as.</summary>
public Type InterfaceType { get; } = interfaceType;

/// <summary>
/// The lifetime of the forwarding registration. Defaults to <see cref="ServiceForwardingLifetime.Transient"/>.
/// </summary>
public ServiceForwardingLifetime Lifetime { get; init; } = ServiceForwardingLifetime.Transient;
}
5 changes: 5 additions & 0 deletions src/Cove.Core/DTOs/DTOs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1213,6 +1213,11 @@ public record ApiKeyResponse(string ApiKey);
// ===== CONFIG DTOs =====
public record SystemStatusDto(
string Version,
// Semver-clean host contract version (major.minor.patch, no prerelease suffix). This is the
// value extensions pin their minimum-host-version requirement against; the SPA/SDK read it to
// negotiate compatibility. Distinct from Version, which is the full display string shown on the
// About / Runtime Status pages. Both derive from the same single version source.
string ContractVersion,
string? AppDir,
string? ConfigFile,
string DatabasePath,
Expand Down
4 changes: 4 additions & 0 deletions src/Cove.Plugins/Cove.Plugins.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
<ProjectReference Include="..\Cove.Core\Cove.Core.csproj" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="Cove.Tests" />
</ItemGroup>

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
Expand Down
Loading