Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -0,0 +1,4 @@
# StellaOps.Notify.Storage.Mongo — Agent Charter
## Mission
Implement Mongo persistence (rules, channels, deliveries, digests, locks, audit) per `docs/ARCHITECTURE_NOTIFY.md`.

View File

@@ -0,0 +1,31 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Notify.Storage.Mongo.Documents;
public sealed class NotifyAuditEntryDocument
{
[BsonId]
public ObjectId Id { get; init; }
[BsonElement("tenantId")]
public required string TenantId { get; init; }
[BsonElement("actor")]
public required string Actor { get; init; }
[BsonElement("action")]
public required string Action { get; init; }
[BsonElement("entityId")]
public string EntityId { get; init; } = string.Empty;
[BsonElement("entityType")]
public string EntityType { get; init; } = string.Empty;
[BsonElement("timestamp")]
public required DateTimeOffset Timestamp { get; init; }
[BsonElement("payload")]
public BsonDocument Payload { get; init; } = new();
}

View File

@@ -0,0 +1,39 @@
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Notify.Storage.Mongo.Documents;
public sealed class NotifyDigestDocument
{
[BsonId]
public string Id { get; set; } = string.Empty;
[BsonElement("tenantId")]
public required string TenantId { get; init; }
[BsonElement("actionKey")]
public required string ActionKey { get; init; }
[BsonElement("window")]
public required string Window { get; init; }
[BsonElement("openedAt")]
public required DateTimeOffset OpenedAt { get; init; }
[BsonElement("status")]
public required string Status { get; init; }
[BsonElement("items")]
public List<NotifyDigestItemDocument> Items { get; init; } = new();
}
public sealed class NotifyDigestItemDocument
{
[BsonElement("eventId")]
public string EventId { get; init; } = string.Empty;
[BsonElement("scope")]
public Dictionary<string, string> Scope { get; init; } = new();
[BsonElement("delta")]
public Dictionary<string, int> Delta { get; init; } = new();
}

View File

@@ -0,0 +1,24 @@
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Notify.Storage.Mongo.Documents;
public sealed class NotifyLockDocument
{
[BsonId]
public string Id { get; set; } = string.Empty;
[BsonElement("tenantId")]
public required string TenantId { get; init; }
[BsonElement("resource")]
public required string Resource { get; init; }
[BsonElement("acquiredAt")]
public required DateTimeOffset AcquiredAt { get; init; }
[BsonElement("expiresAt")]
public required DateTimeOffset ExpiresAt { get; init; }
[BsonElement("owner")]
public string Owner { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,45 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using StellaOps.Notify.Storage.Mongo.Options;
namespace StellaOps.Notify.Storage.Mongo.Internal;
internal sealed class NotifyMongoContext
{
public NotifyMongoContext(IOptions<NotifyMongoOptions> options, ILogger<NotifyMongoContext> logger)
{
ArgumentNullException.ThrowIfNull(logger);
var value = options?.Value ?? throw new ArgumentNullException(nameof(options));
if (string.IsNullOrWhiteSpace(value.ConnectionString))
{
throw new InvalidOperationException("Notify Mongo connection string is not configured.");
}
if (string.IsNullOrWhiteSpace(value.Database))
{
throw new InvalidOperationException("Notify Mongo database name is not configured.");
}
Client = new MongoClient(value.ConnectionString);
var settings = new MongoDatabaseSettings();
if (value.UseMajorityReadConcern)
{
settings.ReadConcern = ReadConcern.Majority;
}
if (value.UseMajorityWriteConcern)
{
settings.WriteConcern = WriteConcern.WMajority;
}
Database = Client.GetDatabase(value.Database, settings);
Options = value;
}
public MongoClient Client { get; }
public IMongoDatabase Database { get; }
public NotifyMongoOptions Options { get; }
}

View File

@@ -0,0 +1,32 @@
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Storage.Mongo.Migrations;
namespace StellaOps.Notify.Storage.Mongo.Internal;
internal interface INotifyMongoInitializer
{
Task EnsureIndexesAsync(CancellationToken cancellationToken = default);
}
internal sealed class NotifyMongoInitializer : INotifyMongoInitializer
{
private readonly NotifyMongoContext _context;
private readonly NotifyMongoMigrationRunner _migrationRunner;
private readonly ILogger<NotifyMongoInitializer> _logger;
public NotifyMongoInitializer(
NotifyMongoContext context,
NotifyMongoMigrationRunner migrationRunner,
ILogger<NotifyMongoInitializer> logger)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_migrationRunner = migrationRunner ?? throw new ArgumentNullException(nameof(migrationRunner));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task EnsureIndexesAsync(CancellationToken cancellationToken = default)
{
_logger.LogInformation("Ensuring Notify Mongo migrations are applied for database {Database}.", _context.Options.Database);
await _migrationRunner.RunAsync(cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,49 @@
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using StellaOps.Notify.Storage.Mongo.Internal;
namespace StellaOps.Notify.Storage.Mongo.Migrations;
internal sealed class EnsureNotifyCollectionsMigration : INotifyMongoMigration
{
private readonly ILogger<EnsureNotifyCollectionsMigration> _logger;
public EnsureNotifyCollectionsMigration(ILogger<EnsureNotifyCollectionsMigration> logger)
=> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
public string Id => "20251019_notify_collections_v1";
public async ValueTask ExecuteAsync(NotifyMongoContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var requiredCollections = new[]
{
context.Options.RulesCollection,
context.Options.ChannelsCollection,
context.Options.TemplatesCollection,
context.Options.DeliveriesCollection,
context.Options.DigestsCollection,
context.Options.LocksCollection,
context.Options.AuditCollection,
context.Options.MigrationsCollection
};
var cursor = await context.Database
.ListCollectionNamesAsync(cancellationToken: cancellationToken)
.ConfigureAwait(false);
var existingNames = await cursor.ToListAsync(cancellationToken).ConfigureAwait(false);
foreach (var collection in requiredCollections)
{
if (existingNames.Contains(collection, StringComparer.Ordinal))
{
continue;
}
_logger.LogInformation("Creating Notify Mongo collection '{CollectionName}'.", collection);
await context.Database.CreateCollectionAsync(collection, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
}

View File

@@ -0,0 +1,165 @@
using System;
using System.Threading.Tasks;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Notify.Storage.Mongo.Internal;
namespace StellaOps.Notify.Storage.Mongo.Migrations;
internal sealed class EnsureNotifyIndexesMigration : INotifyMongoMigration
{
public string Id => "20251019_notify_indexes_v1";
public async ValueTask ExecuteAsync(NotifyMongoContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
await EnsureRulesIndexesAsync(context, cancellationToken).ConfigureAwait(false);
await EnsureChannelsIndexesAsync(context, cancellationToken).ConfigureAwait(false);
await EnsureTemplatesIndexesAsync(context, cancellationToken).ConfigureAwait(false);
await EnsureDeliveriesIndexesAsync(context, cancellationToken).ConfigureAwait(false);
await EnsureDigestsIndexesAsync(context, cancellationToken).ConfigureAwait(false);
await EnsureLocksIndexesAsync(context, cancellationToken).ConfigureAwait(false);
await EnsureAuditIndexesAsync(context, cancellationToken).ConfigureAwait(false);
}
private static async Task EnsureRulesIndexesAsync(NotifyMongoContext context, CancellationToken cancellationToken)
{
var collection = context.Database.GetCollection<BsonDocument>(context.Options.RulesCollection);
var keys = Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("enabled");
var model = new CreateIndexModel<BsonDocument>(keys, new CreateIndexOptions
{
Name = "tenant_enabled"
});
await collection.Indexes.CreateOneAsync(model, cancellationToken: cancellationToken).ConfigureAwait(false);
}
private static async Task EnsureChannelsIndexesAsync(NotifyMongoContext context, CancellationToken cancellationToken)
{
var collection = context.Database.GetCollection<BsonDocument>(context.Options.ChannelsCollection);
var keys = Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("type")
.Ascending("enabled");
var model = new CreateIndexModel<BsonDocument>(keys, new CreateIndexOptions
{
Name = "tenant_type_enabled"
});
await collection.Indexes.CreateOneAsync(model, cancellationToken: cancellationToken).ConfigureAwait(false);
}
private static async Task EnsureTemplatesIndexesAsync(NotifyMongoContext context, CancellationToken cancellationToken)
{
var collection = context.Database.GetCollection<BsonDocument>(context.Options.TemplatesCollection);
var keys = Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("channelType")
.Ascending("key")
.Ascending("locale");
var model = new CreateIndexModel<BsonDocument>(keys, new CreateIndexOptions
{
Name = "tenant_channel_key_locale",
Unique = true
});
await collection.Indexes.CreateOneAsync(model, cancellationToken: cancellationToken).ConfigureAwait(false);
}
private static async Task EnsureDeliveriesIndexesAsync(NotifyMongoContext context, CancellationToken cancellationToken)
{
var collection = context.Database.GetCollection<BsonDocument>(context.Options.DeliveriesCollection);
var keys = Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Descending("sortKey");
var sortModel = new CreateIndexModel<BsonDocument>(keys, new CreateIndexOptions
{
Name = "tenant_sortKey"
});
await collection.Indexes.CreateOneAsync(sortModel, cancellationToken: cancellationToken).ConfigureAwait(false);
var statusModel = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys.Ascending("tenantId").Ascending("status"),
new CreateIndexOptions
{
Name = "tenant_status"
});
await collection.Indexes.CreateOneAsync(statusModel, cancellationToken: cancellationToken).ConfigureAwait(false);
if (context.Options.DeliveryHistoryRetention > TimeSpan.Zero)
{
var ttlModel = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys.Ascending("completedAt"),
new CreateIndexOptions
{
Name = "completedAt_ttl",
ExpireAfter = context.Options.DeliveryHistoryRetention
});
await collection.Indexes.CreateOneAsync(ttlModel, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
private static async Task EnsureDigestsIndexesAsync(NotifyMongoContext context, CancellationToken cancellationToken)
{
var collection = context.Database.GetCollection<BsonDocument>(context.Options.DigestsCollection);
var keys = Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Ascending("actionKey");
var model = new CreateIndexModel<BsonDocument>(keys, new CreateIndexOptions
{
Name = "tenant_actionKey"
});
await collection.Indexes.CreateOneAsync(model, cancellationToken: cancellationToken).ConfigureAwait(false);
}
private static async Task EnsureLocksIndexesAsync(NotifyMongoContext context, CancellationToken cancellationToken)
{
var collection = context.Database.GetCollection<BsonDocument>(context.Options.LocksCollection);
var uniqueModel = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys.Ascending("tenantId").Ascending("resource"),
new CreateIndexOptions
{
Name = "tenant_resource",
Unique = true
});
await collection.Indexes.CreateOneAsync(uniqueModel, cancellationToken: cancellationToken).ConfigureAwait(false);
var ttlModel = new CreateIndexModel<BsonDocument>(
Builders<BsonDocument>.IndexKeys.Ascending("expiresAt"),
new CreateIndexOptions
{
Name = "expiresAt_ttl",
ExpireAfter = TimeSpan.Zero
});
await collection.Indexes.CreateOneAsync(ttlModel, cancellationToken: cancellationToken).ConfigureAwait(false);
}
private static async Task EnsureAuditIndexesAsync(NotifyMongoContext context, CancellationToken cancellationToken)
{
var collection = context.Database.GetCollection<BsonDocument>(context.Options.AuditCollection);
var keys = Builders<BsonDocument>.IndexKeys
.Ascending("tenantId")
.Descending("timestamp");
var model = new CreateIndexModel<BsonDocument>(keys, new CreateIndexOptions
{
Name = "tenant_timestamp"
});
await collection.Indexes.CreateOneAsync(model, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,12 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Notify.Storage.Mongo.Internal;
namespace StellaOps.Notify.Storage.Mongo.Migrations;
internal interface INotifyMongoMigration
{
string Id { get; }
ValueTask ExecuteAsync(NotifyMongoContext context, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,16 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Notify.Storage.Mongo.Migrations;
internal sealed class NotifyMongoMigrationRecord
{
[BsonId]
public ObjectId Id { get; init; }
[BsonElement("migrationId")]
public required string MigrationId { get; init; }
[BsonElement("appliedAt")]
public required DateTimeOffset AppliedAt { get; init; }
}

View File

@@ -0,0 +1,78 @@
using Microsoft.Extensions.Logging;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Notify.Storage.Mongo.Internal;
namespace StellaOps.Notify.Storage.Mongo.Migrations;
internal sealed class NotifyMongoMigrationRunner
{
private readonly NotifyMongoContext _context;
private readonly IReadOnlyList<INotifyMongoMigration> _migrations;
private readonly ILogger<NotifyMongoMigrationRunner> _logger;
public NotifyMongoMigrationRunner(
NotifyMongoContext context,
IEnumerable<INotifyMongoMigration> migrations,
ILogger<NotifyMongoMigrationRunner> logger)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
ArgumentNullException.ThrowIfNull(migrations);
_migrations = migrations.OrderBy(migration => migration.Id, StringComparer.Ordinal).ToArray();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask RunAsync(CancellationToken cancellationToken)
{
if (_migrations.Count == 0)
{
return;
}
var collection = _context.Database.GetCollection<NotifyMongoMigrationRecord>(_context.Options.MigrationsCollection);
await EnsureMigrationIndexAsync(collection, cancellationToken).ConfigureAwait(false);
var applied = await collection
.Find(FilterDefinition<NotifyMongoMigrationRecord>.Empty)
.Project(record => record.MigrationId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var appliedSet = applied.ToHashSet(StringComparer.Ordinal);
foreach (var migration in _migrations)
{
if (appliedSet.Contains(migration.Id))
{
continue;
}
_logger.LogInformation("Applying Notify Mongo migration {MigrationId}.", migration.Id);
await migration.ExecuteAsync(_context, cancellationToken).ConfigureAwait(false);
var record = new NotifyMongoMigrationRecord
{
Id = ObjectId.GenerateNewId(),
MigrationId = migration.Id,
AppliedAt = DateTimeOffset.UtcNow
};
await collection.InsertOneAsync(record, cancellationToken: cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Completed Notify Mongo migration {MigrationId}.", migration.Id);
}
}
private static async Task EnsureMigrationIndexAsync(
IMongoCollection<NotifyMongoMigrationRecord> collection,
CancellationToken cancellationToken)
{
var keys = Builders<NotifyMongoMigrationRecord>.IndexKeys.Ascending(record => record.MigrationId);
var model = new CreateIndexModel<NotifyMongoMigrationRecord>(keys, new CreateIndexOptions
{
Name = "migrationId_unique",
Unique = true
});
await collection.Indexes.CreateOneAsync(model, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,32 @@
using System;
namespace StellaOps.Notify.Storage.Mongo.Options;
public sealed class NotifyMongoOptions
{
public string ConnectionString { get; set; } = "mongodb://localhost:27017";
public string Database { get; set; } = "stellaops_notify";
public string RulesCollection { get; set; } = "rules";
public string ChannelsCollection { get; set; } = "channels";
public string TemplatesCollection { get; set; } = "templates";
public string DeliveriesCollection { get; set; } = "deliveries";
public string DigestsCollection { get; set; } = "digests";
public string LocksCollection { get; set; } = "locks";
public string AuditCollection { get; set; } = "audit";
public string MigrationsCollection { get; set; } = "_notify_migrations";
public TimeSpan DeliveryHistoryRetention { get; set; } = TimeSpan.FromDays(90);
public bool UseMajorityReadConcern { get; set; } = true;
public bool UseMajorityWriteConcern { get; set; } = true;
}

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Notify.Storage.Mongo.Tests")]

View File

@@ -0,0 +1,10 @@
using StellaOps.Notify.Storage.Mongo.Documents;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
public interface INotifyAuditRepository
{
Task AppendAsync(NotifyAuditEntryDocument entry, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyAuditEntryDocument>> QueryAsync(string tenantId, DateTimeOffset? since, int? limit, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,14 @@
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
public interface INotifyChannelRepository
{
Task UpsertAsync(NotifyChannel channel, CancellationToken cancellationToken = default);
Task<NotifyChannel?> GetAsync(string tenantId, string channelId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyChannel>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
Task DeleteAsync(string tenantId, string channelId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,20 @@
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
public interface INotifyDeliveryRepository
{
Task AppendAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default);
Task UpdateAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default);
Task<NotifyDelivery?> GetAsync(string tenantId, string deliveryId, CancellationToken cancellationToken = default);
Task<NotifyDeliveryQueryResult> QueryAsync(
string tenantId,
DateTimeOffset? since,
string? status,
int? limit,
string? continuationToken = null,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,12 @@
using StellaOps.Notify.Storage.Mongo.Documents;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
public interface INotifyDigestRepository
{
Task<NotifyDigestDocument?> GetAsync(string tenantId, string actionKey, CancellationToken cancellationToken = default);
Task UpsertAsync(NotifyDigestDocument document, CancellationToken cancellationToken = default);
Task RemoveAsync(string tenantId, string actionKey, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,8 @@
namespace StellaOps.Notify.Storage.Mongo.Repositories;
public interface INotifyLockRepository
{
Task<bool> TryAcquireAsync(string tenantId, string resource, string owner, TimeSpan ttl, CancellationToken cancellationToken = default);
Task ReleaseAsync(string tenantId, string resource, string owner, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,14 @@
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
public interface INotifyRuleRepository
{
Task UpsertAsync(NotifyRule rule, CancellationToken cancellationToken = default);
Task<NotifyRule?> GetAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyRule>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
Task DeleteAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,14 @@
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
public interface INotifyTemplateRepository
{
Task UpsertAsync(NotifyTemplate template, CancellationToken cancellationToken = default);
Task<NotifyTemplate?> GetAsync(string tenantId, string templateId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyTemplate>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
Task DeleteAsync(string tenantId, string templateId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,40 @@
using MongoDB.Driver;
using StellaOps.Notify.Storage.Mongo.Documents;
using StellaOps.Notify.Storage.Mongo.Internal;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
internal sealed class NotifyAuditRepository : INotifyAuditRepository
{
private readonly IMongoCollection<NotifyAuditEntryDocument> _collection;
public NotifyAuditRepository(NotifyMongoContext context)
{
ArgumentNullException.ThrowIfNull(context);
_collection = context.Database.GetCollection<NotifyAuditEntryDocument>(context.Options.AuditCollection);
}
public async Task AppendAsync(NotifyAuditEntryDocument entry, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entry);
await _collection.InsertOneAsync(entry, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<NotifyAuditEntryDocument>> QueryAsync(string tenantId, DateTimeOffset? since, int? limit, CancellationToken cancellationToken = default)
{
var filter = Builders<NotifyAuditEntryDocument>.Filter.Eq(x => x.TenantId, tenantId);
if (since is not null)
{
filter &= Builders<NotifyAuditEntryDocument>.Filter.Gte(x => x.Timestamp, since.Value);
}
var ordered = _collection.Find(filter).SortByDescending(x => x.Timestamp);
IFindFluent<NotifyAuditEntryDocument, NotifyAuditEntryDocument> query = ordered;
if (limit is > 0)
{
query = query.Limit(limit);
}
return await query.ToListAsync(cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,70 @@
using System.Linq;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Internal;
using StellaOps.Notify.Storage.Mongo.Serialization;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
internal sealed class NotifyChannelRepository : INotifyChannelRepository
{
private readonly IMongoCollection<BsonDocument> _collection;
public NotifyChannelRepository(NotifyMongoContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
_collection = context.Database.GetCollection<BsonDocument>(context.Options.ChannelsCollection);
}
public async Task UpsertAsync(NotifyChannel channel, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(channel);
var document = NotifyChannelDocumentMapper.ToBsonDocument(channel);
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(channel.TenantId, channel.ChannelId));
await _collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
public async Task<NotifyChannel?> GetAsync(string tenantId, string channelId, CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, channelId))
& Builders<BsonDocument>.Filter.Or(
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value));
var document = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return document is null ? null : NotifyChannelDocumentMapper.FromBsonDocument(document);
}
public async Task<IReadOnlyList<NotifyChannel>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("tenantId", tenantId)
& Builders<BsonDocument>.Filter.Or(
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value));
var cursor = await _collection.Find(filter).ToListAsync(cancellationToken).ConfigureAwait(false);
return cursor.Select(NotifyChannelDocumentMapper.FromBsonDocument).ToArray();
}
public async Task DeleteAsync(string tenantId, string channelId, CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, channelId));
await _collection.UpdateOneAsync(filter,
Builders<BsonDocument>.Update.Set("deletedAt", DateTime.UtcNow).Set("enabled", false),
new UpdateOptions { IsUpsert = false },
cancellationToken).ConfigureAwait(false);
}
private static string CreateDocumentId(string tenantId, string resourceId)
=> string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) =>
{
value.tenantId.AsSpan().CopyTo(span);
span[value.tenantId.Length] = ':';
value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
});
}

View File

@@ -0,0 +1,6 @@
using System.Collections.Generic;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
public sealed record NotifyDeliveryQueryResult(IReadOnlyList<NotifyDelivery> Items, string? ContinuationToken);

View File

@@ -0,0 +1,179 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Internal;
using StellaOps.Notify.Storage.Mongo.Serialization;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
internal sealed class NotifyDeliveryRepository : INotifyDeliveryRepository
{
private readonly IMongoCollection<BsonDocument> _collection;
public NotifyDeliveryRepository(NotifyMongoContext context)
{
ArgumentNullException.ThrowIfNull(context);
_collection = context.Database.GetCollection<BsonDocument>(context.Options.DeliveriesCollection);
}
public Task AppendAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default)
=> UpdateAsync(delivery, cancellationToken);
public async Task UpdateAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(delivery);
var document = NotifyDeliveryDocumentMapper.ToBsonDocument(delivery);
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(delivery.TenantId, delivery.DeliveryId));
await _collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
public async Task<NotifyDelivery?> GetAsync(string tenantId, string deliveryId, CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, deliveryId));
var document = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return document is null ? null : NotifyDeliveryDocumentMapper.FromBsonDocument(document);
}
public async Task<NotifyDeliveryQueryResult> QueryAsync(
string tenantId,
DateTimeOffset? since,
string? status,
int? limit,
string? continuationToken = null,
CancellationToken cancellationToken = default)
{
var builder = Builders<BsonDocument>.Filter;
var filter = builder.Eq("tenantId", tenantId);
if (since is not null)
{
filter &= builder.Gte("sortKey", since.Value.UtcDateTime);
}
if (!string.IsNullOrWhiteSpace(status))
{
var statuses = status
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(static value => value.ToLowerInvariant())
.ToArray();
if (statuses.Length == 1)
{
filter &= builder.Eq("status", statuses[0]);
}
else if (statuses.Length > 1)
{
filter &= builder.In("status", statuses);
}
}
if (!string.IsNullOrWhiteSpace(continuationToken))
{
if (!TryParseContinuationToken(continuationToken, out var continuationSortKey, out var continuationId))
{
throw new ArgumentException("The continuation token is invalid.", nameof(continuationToken));
}
var lessThanSort = builder.Lt("sortKey", continuationSortKey);
var equalSortLowerId = builder.And(builder.Eq("sortKey", continuationSortKey), builder.Lte("_id", continuationId));
filter &= builder.Or(lessThanSort, equalSortLowerId);
}
var find = _collection.Find(filter)
.Sort(Builders<BsonDocument>.Sort.Descending("sortKey").Descending("_id"));
List<BsonDocument> documents;
if (limit is > 0)
{
documents = await find.Limit(limit.Value + 1).ToListAsync(cancellationToken).ConfigureAwait(false);
}
else
{
documents = await find.ToListAsync(cancellationToken).ConfigureAwait(false);
}
string? nextToken = null;
if (limit is > 0 && documents.Count > limit.Value)
{
var overflow = documents[^1];
documents.RemoveAt(documents.Count - 1);
nextToken = BuildContinuationToken(overflow);
}
var deliveries = documents.Select(NotifyDeliveryDocumentMapper.FromBsonDocument).ToArray();
return new NotifyDeliveryQueryResult(deliveries, nextToken);
}
private static string CreateDocumentId(string tenantId, string resourceId)
=> string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) =>
{
value.tenantId.AsSpan().CopyTo(span);
span[value.tenantId.Length] = ':';
value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
});
private static string BuildContinuationToken(BsonDocument document)
{
var sortKey = ResolveSortKey(document);
if (!document.TryGetValue("_id", out var idValue) || !idValue.IsString)
{
throw new InvalidOperationException("Delivery document missing string _id required for continuation token.");
}
return BuildContinuationToken(sortKey, idValue.AsString);
}
private static DateTime ResolveSortKey(BsonDocument document)
{
if (document.TryGetValue("sortKey", out var sortValue) && sortValue.IsValidDateTime)
{
return sortValue.ToUniversalTime();
}
if (document.TryGetValue("completedAt", out var completed) && completed.IsValidDateTime)
{
return completed.ToUniversalTime();
}
if (document.TryGetValue("sentAt", out var sent) && sent.IsValidDateTime)
{
return sent.ToUniversalTime();
}
var created = document["createdAt"];
return created.ToUniversalTime();
}
private static string BuildContinuationToken(DateTime sortKey, string id)
=> FormattableString.Invariant($"{sortKey:O}|{id}");
private static bool TryParseContinuationToken(string token, out DateTime sortKey, out string id)
{
sortKey = default;
id = string.Empty;
var parts = token.Split('|', 2, StringSplitOptions.TrimEntries);
if (parts.Length != 2)
{
return false;
}
if (!DateTime.TryParseExact(parts[0], "O", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsedSort))
{
return false;
}
if (string.IsNullOrWhiteSpace(parts[1]))
{
return false;
}
sortKey = parsedSort.ToUniversalTime();
id = parts[1];
return true;
}
}

View File

@@ -0,0 +1,44 @@
using MongoDB.Driver;
using StellaOps.Notify.Storage.Mongo.Documents;
using StellaOps.Notify.Storage.Mongo.Internal;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
internal sealed class NotifyDigestRepository : INotifyDigestRepository
{
private readonly IMongoCollection<NotifyDigestDocument> _collection;
public NotifyDigestRepository(NotifyMongoContext context)
{
ArgumentNullException.ThrowIfNull(context);
_collection = context.Database.GetCollection<NotifyDigestDocument>(context.Options.DigestsCollection);
}
public async Task<NotifyDigestDocument?> GetAsync(string tenantId, string actionKey, CancellationToken cancellationToken = default)
{
var filter = Builders<NotifyDigestDocument>.Filter.Eq(x => x.Id, CreateDocumentId(tenantId, actionKey));
return await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
}
public async Task UpsertAsync(NotifyDigestDocument document, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(document);
document.Id = CreateDocumentId(document.TenantId, document.ActionKey);
var filter = Builders<NotifyDigestDocument>.Filter.Eq(x => x.Id, document.Id);
await _collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
public async Task RemoveAsync(string tenantId, string actionKey, CancellationToken cancellationToken = default)
{
var filter = Builders<NotifyDigestDocument>.Filter.Eq(x => x.Id, CreateDocumentId(tenantId, actionKey));
await _collection.DeleteOneAsync(filter, cancellationToken).ConfigureAwait(false);
}
private static string CreateDocumentId(string tenantId, string actionKey)
=> string.Create(tenantId.Length + actionKey.Length + 1, (tenantId, actionKey), static (span, value) =>
{
value.tenantId.AsSpan().CopyTo(span);
span[value.tenantId.Length] = ':';
value.actionKey.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
});
}

View File

@@ -0,0 +1,71 @@
using MongoDB.Driver;
using StellaOps.Notify.Storage.Mongo.Documents;
using StellaOps.Notify.Storage.Mongo.Internal;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
internal sealed class NotifyLockRepository : INotifyLockRepository
{
private readonly IMongoCollection<NotifyLockDocument> _collection;
public NotifyLockRepository(NotifyMongoContext context)
{
ArgumentNullException.ThrowIfNull(context);
_collection = context.Database.GetCollection<NotifyLockDocument>(context.Options.LocksCollection);
}
public async Task<bool> TryAcquireAsync(string tenantId, string resource, string owner, TimeSpan ttl, CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var document = new NotifyLockDocument
{
Id = CreateDocumentId(tenantId, resource),
TenantId = tenantId,
Resource = resource,
Owner = owner,
AcquiredAt = now,
ExpiresAt = now.Add(ttl)
};
var candidateFilter = Builders<NotifyLockDocument>.Filter.Eq(x => x.Id, document.Id);
var takeoverFilter = candidateFilter & Builders<NotifyLockDocument>.Filter.Lt(x => x.ExpiresAt, now.UtcDateTime);
var sameOwnerFilter = candidateFilter & Builders<NotifyLockDocument>.Filter.Eq(x => x.Owner, owner);
var update = Builders<NotifyLockDocument>.Update
.Set(x => x.TenantId, document.TenantId)
.Set(x => x.Resource, document.Resource)
.Set(x => x.Owner, document.Owner)
.Set(x => x.AcquiredAt, document.AcquiredAt)
.Set(x => x.ExpiresAt, document.ExpiresAt);
try
{
var result = await _collection.UpdateOneAsync(
takeoverFilter | sameOwnerFilter,
update.SetOnInsert(x => x.Id, document.Id),
new UpdateOptions { IsUpsert = true },
cancellationToken).ConfigureAwait(false);
return result.MatchedCount > 0 || result.UpsertedId != null;
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return false;
}
}
public async Task ReleaseAsync(string tenantId, string resource, string owner, CancellationToken cancellationToken = default)
{
var filter = Builders<NotifyLockDocument>.Filter.Eq(x => x.Id, CreateDocumentId(tenantId, resource))
& Builders<NotifyLockDocument>.Filter.Eq(x => x.Owner, owner);
await _collection.DeleteOneAsync(filter, cancellationToken).ConfigureAwait(false);
}
private static string CreateDocumentId(string tenantId, string resourceId)
=> string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) =>
{
value.tenantId.AsSpan().CopyTo(span);
span[value.tenantId.Length] = ':';
value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
});
}

View File

@@ -0,0 +1,73 @@
using System;
using System.Linq;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Internal;
using StellaOps.Notify.Storage.Mongo.Serialization;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
internal sealed class NotifyRuleRepository : INotifyRuleRepository
{
private readonly IMongoCollection<BsonDocument> _collection;
public NotifyRuleRepository(NotifyMongoContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
_collection = context.Database.GetCollection<BsonDocument>(context.Options.RulesCollection);
}
public async Task UpsertAsync(NotifyRule rule, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(rule);
var document = NotifyRuleDocumentMapper.ToBsonDocument(rule);
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(rule.TenantId, rule.RuleId));
await _collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
public async Task<NotifyRule?> GetAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, ruleId))
& Builders<BsonDocument>.Filter.Or(
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value));
var document = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return document is null ? null : NotifyRuleDocumentMapper.FromBsonDocument(document);
}
public async Task<IReadOnlyList<NotifyRule>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("tenantId", tenantId)
& Builders<BsonDocument>.Filter.Or(
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value));
var cursor = await _collection.Find(filter).ToListAsync(cancellationToken).ConfigureAwait(false);
return cursor.Select(NotifyRuleDocumentMapper.FromBsonDocument).ToArray();
}
public async Task DeleteAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, ruleId));
await _collection.UpdateOneAsync(filter,
Builders<BsonDocument>.Update
.Set("deletedAt", DateTime.UtcNow)
.Set("enabled", false),
new UpdateOptions { IsUpsert = false },
cancellationToken).ConfigureAwait(false);
}
private static string CreateDocumentId(string tenantId, string resourceId)
=> string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) =>
{
value.tenantId.AsSpan().CopyTo(span);
span[value.tenantId.Length] = ':';
value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
});
}

View File

@@ -0,0 +1,70 @@
using System.Linq;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Internal;
using StellaOps.Notify.Storage.Mongo.Serialization;
namespace StellaOps.Notify.Storage.Mongo.Repositories;
internal sealed class NotifyTemplateRepository : INotifyTemplateRepository
{
private readonly IMongoCollection<BsonDocument> _collection;
public NotifyTemplateRepository(NotifyMongoContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
_collection = context.Database.GetCollection<BsonDocument>(context.Options.TemplatesCollection);
}
public async Task UpsertAsync(NotifyTemplate template, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(template);
var document = NotifyTemplateDocumentMapper.ToBsonDocument(template);
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(template.TenantId, template.TemplateId));
await _collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
}
public async Task<NotifyTemplate?> GetAsync(string tenantId, string templateId, CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, templateId))
& Builders<BsonDocument>.Filter.Or(
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value));
var document = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return document is null ? null : NotifyTemplateDocumentMapper.FromBsonDocument(document);
}
public async Task<IReadOnlyList<NotifyTemplate>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("tenantId", tenantId)
& Builders<BsonDocument>.Filter.Or(
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value));
var cursor = await _collection.Find(filter).ToListAsync(cancellationToken).ConfigureAwait(false);
return cursor.Select(NotifyTemplateDocumentMapper.FromBsonDocument).ToArray();
}
public async Task DeleteAsync(string tenantId, string templateId, CancellationToken cancellationToken = default)
{
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, templateId));
await _collection.UpdateOneAsync(filter,
Builders<BsonDocument>.Update.Set("deletedAt", DateTime.UtcNow),
new UpdateOptions { IsUpsert = false },
cancellationToken).ConfigureAwait(false);
}
private static string CreateDocumentId(string tenantId, string resourceId)
=> string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) =>
{
value.tenantId.AsSpan().CopyTo(span);
span[value.tenantId.Length] = ':';
value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
});
}

View File

@@ -0,0 +1,129 @@
using System.Globalization;
using System.Text.Json.Nodes;
using MongoDB.Bson;
using MongoDB.Bson.IO;
namespace StellaOps.Notify.Storage.Mongo.Serialization;
internal static class BsonDocumentJsonExtensions
{
public static JsonNode ToCanonicalJsonNode(this BsonDocument document, params string[] fieldsToRemove)
{
ArgumentNullException.ThrowIfNull(document);
var clone = document.DeepClone().AsBsonDocument;
clone.Remove("_id");
if (fieldsToRemove is { Length: > 0 })
{
foreach (var field in fieldsToRemove)
{
clone.Remove(field);
}
}
var json = clone.ToJson(new JsonWriterSettings
{
OutputMode = JsonOutputMode.RelaxedExtendedJson,
Indent = false
});
var node = JsonNode.Parse(json) ?? throw new InvalidOperationException("Unable to parse BsonDocument JSON.");
return NormalizeExtendedJson(node);
}
private static JsonNode NormalizeExtendedJson(JsonNode node)
{
if (node is JsonObject obj)
{
if (TryConvertExtendedDate(obj, out var replacement))
{
return replacement;
}
foreach (var property in obj.ToList())
{
if (property.Value is null)
{
continue;
}
var normalized = NormalizeExtendedJson(property.Value);
if (!ReferenceEquals(normalized, property.Value))
{
obj[property.Key] = normalized;
}
}
return obj;
}
if (node is JsonArray array)
{
for (var i = 0; i < array.Count; i++)
{
if (array[i] is null)
{
continue;
}
var normalized = NormalizeExtendedJson(array[i]!);
if (!ReferenceEquals(normalized, array[i]))
{
array[i] = normalized;
}
}
return array;
}
return node;
}
private static bool TryConvertExtendedDate(JsonObject obj, out JsonNode replacement)
{
replacement = obj;
if (obj.Count != 1 || !obj.TryGetPropertyValue("$date", out var value) || value is null)
{
return false;
}
if (value is JsonValue directValue)
{
if (directValue.TryGetValue(out string? dateString) && TryParseIso(dateString, out var iso))
{
replacement = JsonValue.Create(iso);
return true;
}
if (directValue.TryGetValue(out long epochMilliseconds))
{
replacement = JsonValue.Create(DateTimeOffset.FromUnixTimeMilliseconds(epochMilliseconds).ToString("O"));
return true;
}
}
else if (value is JsonObject nested && nested.TryGetPropertyValue("$numberLong", out var numberNode) && numberNode is JsonValue numberValue && numberValue.TryGetValue(out string? numberString) && long.TryParse(numberString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var ms))
{
replacement = JsonValue.Create(DateTimeOffset.FromUnixTimeMilliseconds(ms).ToString("O"));
return true;
}
return false;
}
private static bool TryParseIso(string? value, out string iso)
{
iso = string.Empty;
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
if (DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsed))
{
iso = parsed.ToUniversalTime().ToString("O");
return true;
}
return false;
}
}

View File

@@ -0,0 +1,33 @@
using System.Text.Json.Nodes;
using MongoDB.Bson;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Storage.Mongo.Serialization;
internal static class NotifyChannelDocumentMapper
{
public static BsonDocument ToBsonDocument(NotifyChannel channel)
{
ArgumentNullException.ThrowIfNull(channel);
var json = NotifyCanonicalJsonSerializer.Serialize(channel);
var document = BsonDocument.Parse(json);
document["_id"] = BsonValue.Create(CreateDocumentId(channel.TenantId, channel.ChannelId));
return document;
}
public static NotifyChannel FromBsonDocument(BsonDocument document)
{
ArgumentNullException.ThrowIfNull(document);
var node = document.ToCanonicalJsonNode();
return NotifySchemaMigration.UpgradeChannel(node);
}
private static string CreateDocumentId(string tenantId, string resourceId)
=> string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) =>
{
value.tenantId.AsSpan().CopyTo(span);
span[value.tenantId.Length] = ':';
value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
});
}

View File

@@ -0,0 +1,46 @@
using System.Text.Json.Nodes;
using MongoDB.Bson;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Storage.Mongo.Serialization;
internal static class NotifyDeliveryDocumentMapper
{
public static BsonDocument ToBsonDocument(NotifyDelivery delivery)
{
ArgumentNullException.ThrowIfNull(delivery);
var json = NotifyCanonicalJsonSerializer.Serialize(delivery);
var document = BsonDocument.Parse(json);
document["_id"] = BsonValue.Create(CreateDocumentId(delivery.TenantId, delivery.DeliveryId));
document["tenantId"] = delivery.TenantId;
document["createdAt"] = delivery.CreatedAt.UtcDateTime;
if (delivery.SentAt is not null)
{
document["sentAt"] = delivery.SentAt.Value.UtcDateTime;
}
if (delivery.CompletedAt is not null)
{
document["completedAt"] = delivery.CompletedAt.Value.UtcDateTime;
}
var sortTimestamp = delivery.CompletedAt ?? delivery.SentAt ?? delivery.CreatedAt;
document["sortKey"] = sortTimestamp.UtcDateTime;
return document;
}
public static NotifyDelivery FromBsonDocument(BsonDocument document)
{
ArgumentNullException.ThrowIfNull(document);
var node = document.ToCanonicalJsonNode("sortKey");
return NotifyCanonicalJsonSerializer.Deserialize<NotifyDelivery>(node.ToJsonString());
}
private static string CreateDocumentId(string tenantId, string resourceId)
=> string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) =>
{
value.tenantId.AsSpan().CopyTo(span);
span[value.tenantId.Length] = ':';
value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
});
}

View File

@@ -0,0 +1,33 @@
using System.Text.Json.Nodes;
using MongoDB.Bson;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Storage.Mongo.Serialization;
internal static class NotifyRuleDocumentMapper
{
public static BsonDocument ToBsonDocument(NotifyRule rule)
{
ArgumentNullException.ThrowIfNull(rule);
var json = NotifyCanonicalJsonSerializer.Serialize(rule);
var document = BsonDocument.Parse(json);
document["_id"] = BsonValue.Create(CreateDocumentId(rule.TenantId, rule.RuleId));
return document;
}
public static NotifyRule FromBsonDocument(BsonDocument document)
{
ArgumentNullException.ThrowIfNull(document);
var node = document.ToCanonicalJsonNode();
return NotifySchemaMigration.UpgradeRule(node);
}
private static string CreateDocumentId(string tenantId, string ruleId)
=> string.Create(tenantId.Length + ruleId.Length + 1, (tenantId, ruleId), static (span, value) =>
{
value.tenantId.AsSpan().CopyTo(span);
span[value.tenantId.Length] = ':';
value.ruleId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
});
}

View File

@@ -0,0 +1,33 @@
using System.Text.Json.Nodes;
using MongoDB.Bson;
using StellaOps.Notify.Models;
namespace StellaOps.Notify.Storage.Mongo.Serialization;
internal static class NotifyTemplateDocumentMapper
{
public static BsonDocument ToBsonDocument(NotifyTemplate template)
{
ArgumentNullException.ThrowIfNull(template);
var json = NotifyCanonicalJsonSerializer.Serialize(template);
var document = BsonDocument.Parse(json);
document["_id"] = BsonValue.Create(CreateDocumentId(template.TenantId, template.TemplateId));
return document;
}
public static NotifyTemplate FromBsonDocument(BsonDocument document)
{
ArgumentNullException.ThrowIfNull(document);
var node = document.ToCanonicalJsonNode();
return NotifySchemaMigration.UpgradeTemplate(node);
}
private static string CreateDocumentId(string tenantId, string resourceId)
=> string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) =>
{
value.tenantId.AsSpan().CopyTo(span);
span[value.tenantId.Length] = ':';
value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
});
}

View File

@@ -0,0 +1,33 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Notify.Storage.Mongo.Internal;
using StellaOps.Notify.Storage.Mongo.Migrations;
using StellaOps.Notify.Storage.Mongo.Options;
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notify.Storage.Mongo;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddNotifyMongoStorage(this IServiceCollection services, IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
services.Configure<NotifyMongoOptions>(configuration);
services.AddSingleton<NotifyMongoContext>();
services.AddSingleton<NotifyMongoMigrationRunner>();
services.AddSingleton<INotifyMongoMigration, EnsureNotifyCollectionsMigration>();
services.AddSingleton<INotifyMongoMigration, EnsureNotifyIndexesMigration>();
services.AddSingleton<INotifyMongoInitializer, NotifyMongoInitializer>();
services.AddSingleton<INotifyRuleRepository, NotifyRuleRepository>();
services.AddSingleton<INotifyChannelRepository, NotifyChannelRepository>();
services.AddSingleton<INotifyTemplateRepository, NotifyTemplateRepository>();
services.AddSingleton<INotifyDeliveryRepository, NotifyDeliveryRepository>();
services.AddSingleton<INotifyDigestRepository, NotifyDigestRepository>();
services.AddSingleton<INotifyLockRepository, NotifyLockRepository>();
services.AddSingleton<INotifyAuditRepository, NotifyAuditRepository>();
return services;
}
}

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
<PackageReference Include="MongoDB.Bson" Version="3.5.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,2 @@
# Notify Storage Task Board (Sprint 15)
> Archived 2025-10-26 — storage responsibilities now tracked in `src/Notifier/StellaOps.Notifier` (Sprints 3840).