// Copyright 2024-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/jetstream_versioning.go in the NATS server Go source.
namespace ZB.MOM.NatsNet.Server;
///
/// JetStream API level versioning constants and helpers.
/// Mirrors server/jetstream_versioning.go.
///
public static class JetStreamVersioning
{
/// Maximum supported JetStream API level for this server.
public const int JsApiLevel = 3;
/// Metadata key that carries the required API level for a stream or consumer asset.
public const string JsRequiredLevelMetadataKey = "_nats.req.level";
/// Metadata key that carries the server version that created/updated the asset.
public const string JsServerVersionMetadataKey = "_nats.ver";
/// Metadata key that carries the server API level that created/updated the asset.
public const string JsServerLevelMetadataKey = "_nats.level";
// ---- API level feature gates ----
// These document which API level each feature requires.
// They correspond to the requires() calls in setStaticStreamMetadata / setStaticConsumerMetadata.
/// API level required for per-message TTL and SubjectDeleteMarkerTTL (v2.11).
public const int ApiLevelForTTL = 1;
/// API level required for consumer PauseUntil (v2.11).
public const int ApiLevelForConsumerPause = 1;
/// API level required for priority groups (v2.11).
public const int ApiLevelForPriorityGroups = 1;
/// API level required for counter CRDTs (v2.12).
public const int ApiLevelForCounters = 2;
/// API level required for atomic batch publishing (v2.12).
public const int ApiLevelForAtomicPublish = 2;
/// API level required for message scheduling (v2.12).
public const int ApiLevelForMsgSchedules = 2;
/// API level required for async persist mode (v2.12).
public const int ApiLevelForAsyncPersist = 2;
// ---- Helper methods ----
///
/// Returns the required API level string from stream or consumer metadata,
/// or an empty string if not set.
/// Mirrors getRequiredApiLevel.
///
public static string GetRequiredApiLevel(IDictionary? metadata)
{
if (metadata is not null && metadata.TryGetValue(JsRequiredLevelMetadataKey, out var l) && l.Length > 0)
return l;
return string.Empty;
}
///
/// Returns whether this server supports the required API level encoded in the asset's metadata.
/// Mirrors supportsRequiredApiLevel.
///
public static bool SupportsRequiredApiLevel(IDictionary? metadata)
{
var l = GetRequiredApiLevel(metadata);
if (l.Length == 0) return true;
return int.TryParse(l, out var level) && level <= JsApiLevel;
}
///
/// Removes dynamic (per-response) versioning fields from metadata.
/// These should never be stored; only added in API responses.
/// Mirrors deleteDynamicMetadata.
///
public static void DeleteDynamicMetadata(IDictionary metadata)
{
metadata.Remove(JsServerVersionMetadataKey);
metadata.Remove(JsServerLevelMetadataKey);
}
///
/// Returns whether a request should be rejected based on the Nats-Required-Api-Level header value.
/// Mirrors errorOnRequiredApiLevel.
///
public static bool ErrorOnRequiredApiLevel(string? reqApiLevelHeader)
{
if (string.IsNullOrEmpty(reqApiLevelHeader)) return false;
return !int.TryParse(reqApiLevelHeader, out var minLevel) || JsApiLevel < minLevel;
}
// ---- Stream metadata mutations ----
///
/// Sets the required API level in stream config metadata based on which v2.11+/v2.12+ features
/// the stream config uses. Removes any dynamic fields first.
/// Mirrors setStaticStreamMetadata.
///
public static void SetStaticStreamMetadata(StreamConfig cfg)
{
cfg.Metadata ??= new Dictionary();
DeleteDynamicMetadata(cfg.Metadata);
var requiredApiLevel = 0;
void Requires(int level) { if (level > requiredApiLevel) requiredApiLevel = level; }
if (cfg.AllowMsgTTL || cfg.SubjectDeleteMarkerTTL > TimeSpan.Zero)
Requires(ApiLevelForTTL);
if (cfg.AllowMsgCounter)
Requires(ApiLevelForCounters);
if (cfg.AllowAtomicPublish)
Requires(ApiLevelForAtomicPublish);
if (cfg.AllowMsgSchedules)
Requires(ApiLevelForMsgSchedules);
if (cfg.PersistMode == PersistModeType.AsyncPersistMode)
Requires(ApiLevelForAsyncPersist);
cfg.Metadata[JsRequiredLevelMetadataKey] = requiredApiLevel.ToString();
}
///
/// Returns a shallow copy of the stream config with dynamic versioning fields added to a new
/// metadata dictionary. Does not mutate .
/// Mirrors setDynamicStreamMetadata.
///
public static StreamConfig SetDynamicStreamMetadata(StreamConfig cfg)
{
// Shallow-copy the struct-like record: clone all fields then replace metadata.
var newCfg = cfg.Clone();
newCfg.Metadata = new Dictionary();
if (cfg.Metadata != null)
foreach (var kv in cfg.Metadata)
newCfg.Metadata[kv.Key] = kv.Value;
newCfg.Metadata[JsServerVersionMetadataKey] = ServerConstants.Version;
newCfg.Metadata[JsServerLevelMetadataKey] = JsApiLevel.ToString();
return newCfg;
}
///
/// Copies the required-level versioning field from into
/// , removing dynamic fields and deleting the key if absent in prevCfg.
/// Mirrors copyStreamMetadata.
///
public static void CopyStreamMetadata(StreamConfig cfg, StreamConfig? prevCfg)
{
if (cfg.Metadata != null)
DeleteDynamicMetadata(cfg.Metadata);
SetOrDeleteInStreamMetadata(cfg, prevCfg, JsRequiredLevelMetadataKey);
}
private static void SetOrDeleteInStreamMetadata(StreamConfig cfg, StreamConfig? prevCfg, string key)
{
if (prevCfg?.Metadata != null && prevCfg.Metadata.TryGetValue(key, out var value))
{
cfg.Metadata ??= new Dictionary();
cfg.Metadata[key] = value;
return;
}
if (cfg.Metadata != null)
{
cfg.Metadata.Remove(key);
if (cfg.Metadata.Count == 0)
cfg.Metadata = null;
}
}
// ---- Consumer metadata mutations ----
///
/// Sets the required API level in consumer config metadata based on which v2.11+ features
/// the consumer config uses. Removes any dynamic fields first.
/// Mirrors setStaticConsumerMetadata.
///
public static void SetStaticConsumerMetadata(ConsumerConfig cfg)
{
cfg.Metadata ??= new Dictionary();
DeleteDynamicMetadata(cfg.Metadata);
var requiredApiLevel = 0;
void Requires(int level) { if (level > requiredApiLevel) requiredApiLevel = level; }
if (cfg.PauseUntil.HasValue && cfg.PauseUntil.Value != default)
Requires(ApiLevelForConsumerPause);
if (cfg.PriorityPolicy != PriorityPolicy.PriorityNone
|| cfg.PinnedTTL != TimeSpan.Zero
|| (cfg.PriorityGroups != null && cfg.PriorityGroups.Length > 0))
Requires(ApiLevelForPriorityGroups);
cfg.Metadata[JsRequiredLevelMetadataKey] = requiredApiLevel.ToString();
}
///
/// Returns a shallow copy of the consumer config with dynamic versioning fields added to a new
/// metadata dictionary. Does not mutate .
/// Mirrors setDynamicConsumerMetadata.
///
public static ConsumerConfig SetDynamicConsumerMetadata(ConsumerConfig cfg)
{
var newCfg = new ConsumerConfig();
// Copy all fields via serialisation-free approach: copy properties from cfg
CopyConsumerConfigFields(cfg, newCfg);
newCfg.Metadata = new Dictionary();
if (cfg.Metadata != null)
foreach (var kv in cfg.Metadata)
newCfg.Metadata[kv.Key] = kv.Value;
newCfg.Metadata[JsServerVersionMetadataKey] = ServerConstants.Version;
newCfg.Metadata[JsServerLevelMetadataKey] = JsApiLevel.ToString();
return newCfg;
}
///
/// Returns a shallow copy of the consumer info with dynamic versioning fields added to the
/// config's metadata. Does not mutate .
/// Mirrors setDynamicConsumerInfoMetadata.
///
public static ConsumerInfo SetDynamicConsumerInfoMetadata(ConsumerInfo info)
{
var newInfo = new ConsumerInfo
{
Stream = info.Stream,
Name = info.Name,
Created = info.Created,
Delivered = info.Delivered,
AckFloor = info.AckFloor,
NumAckPending = info.NumAckPending,
NumRedelivered = info.NumRedelivered,
NumWaiting = info.NumWaiting,
NumPending = info.NumPending,
Cluster = info.Cluster,
PushBound = info.PushBound,
Paused = info.Paused,
PauseRemaining = info.PauseRemaining,
TimeStamp = info.TimeStamp,
PriorityGroups = info.PriorityGroups,
Config = info.Config != null ? SetDynamicConsumerMetadata(info.Config) : null,
};
return newInfo;
}
///
/// Copies the required-level versioning field from into
/// , removing dynamic fields and deleting the key if absent in prevCfg.
/// Mirrors copyConsumerMetadata.
///
public static void CopyConsumerMetadata(ConsumerConfig cfg, ConsumerConfig? prevCfg)
{
if (cfg.Metadata != null)
DeleteDynamicMetadata(cfg.Metadata);
SetOrDeleteInConsumerMetadata(cfg, prevCfg, JsRequiredLevelMetadataKey);
}
private static void SetOrDeleteInConsumerMetadata(ConsumerConfig cfg, ConsumerConfig? prevCfg, string key)
{
if (prevCfg?.Metadata != null && prevCfg.Metadata.TryGetValue(key, out var value))
{
cfg.Metadata ??= new Dictionary();
cfg.Metadata[key] = value;
return;
}
if (cfg.Metadata != null)
{
cfg.Metadata.Remove(key);
if (cfg.Metadata.Count == 0)
cfg.Metadata = null;
}
}
// ---- Private helpers ----
///
/// Copies all scalar/reference properties from to ,
/// excluding Metadata (which is set separately by the caller).
///
private static void CopyConsumerConfigFields(ConsumerConfig src, ConsumerConfig dst)
{
dst.DeliverPolicy = src.DeliverPolicy;
dst.OptStartSeq = src.OptStartSeq;
dst.OptStartTime = src.OptStartTime;
dst.DeliverSubject = src.DeliverSubject;
dst.DeliverGroup = src.DeliverGroup;
dst.Durable = src.Durable;
dst.Name = src.Name;
dst.Description = src.Description;
dst.FilterSubject = src.FilterSubject;
dst.FilterSubjects = src.FilterSubjects;
dst.AckPolicy = src.AckPolicy;
dst.AckWait = src.AckWait;
dst.MaxDeliver = src.MaxDeliver;
dst.BackOff = src.BackOff;
dst.ReplayPolicy = src.ReplayPolicy;
dst.RateLimit = src.RateLimit;
dst.SampleFrequency = src.SampleFrequency;
dst.MaxWaiting = src.MaxWaiting;
dst.MaxAckPending = src.MaxAckPending;
dst.FlowControl = src.FlowControl;
dst.Heartbeat = src.Heartbeat;
dst.Direct = src.Direct;
dst.HeadersOnly = src.HeadersOnly;
dst.MaxRequestBatch = src.MaxRequestBatch;
dst.MaxRequestMaxBytes = src.MaxRequestMaxBytes;
dst.MaxRequestExpires = src.MaxRequestExpires;
dst.InactiveThreshold = src.InactiveThreshold;
dst.Replicas = src.Replicas;
dst.MemoryStorage = src.MemoryStorage;
dst.PauseUntil = src.PauseUntil;
dst.PinnedTTL = src.PinnedTTL;
dst.PriorityPolicy = src.PriorityPolicy;
dst.PriorityGroups = src.PriorityGroups;
// Metadata is NOT copied here — caller sets it.
}
}