using System.Text.Json.Serialization; using System.Security.Cryptography; using System.Text; using SurrealDb.Net; using SurrealDb.Net.Models; using ZB.MOM.WW.CBDDC.Persistence.Surreal; namespace ZB.MOM.WW.CBDDC.Sample.Console; public class SampleDbContext : IDisposable { private const string UsersTable = "sample_users"; private const string TodoListsTable = "sample_todo_lists"; private readonly bool _ownsClient; public SampleDbContext( ICBDDCSurrealEmbeddedClient surrealEmbeddedClient, ICBDDCSurrealSchemaInitializer schemaInitializer) { SurrealEmbeddedClient = surrealEmbeddedClient ?? throw new ArgumentNullException(nameof(surrealEmbeddedClient)); SchemaInitializer = schemaInitializer ?? throw new ArgumentNullException(nameof(schemaInitializer)); Users = new SampleSurrealCollection(UsersTable, u => u.Id, SurrealEmbeddedClient, SchemaInitializer); TodoLists = new SampleSurrealCollection(TodoListsTable, t => t.Id, SurrealEmbeddedClient, SchemaInitializer); OplogEntries = new SampleSurrealReadOnlyCollection( CBDDCSurrealSchemaNames.OplogEntriesTable, SurrealEmbeddedClient, SchemaInitializer); } public SampleDbContext(string databasePath) { string normalizedPath = NormalizeDatabasePath(databasePath); string suffix = ComputeDeterministicSuffix(normalizedPath); var options = new CBDDCSurrealEmbeddedOptions { Endpoint = "rocksdb://local", DatabasePath = normalizedPath, Namespace = $"cbddc_sample_{suffix}", Database = $"main_{suffix}" }; SurrealEmbeddedClient = new CBDDCSurrealEmbeddedClient(options); _ownsClient = true; SchemaInitializer = new SampleSurrealSchemaInitializer(SurrealEmbeddedClient); Users = new SampleSurrealCollection(UsersTable, u => u.Id, SurrealEmbeddedClient, SchemaInitializer); TodoLists = new SampleSurrealCollection(TodoListsTable, t => t.Id, SurrealEmbeddedClient, SchemaInitializer); OplogEntries = new SampleSurrealReadOnlyCollection( CBDDCSurrealSchemaNames.OplogEntriesTable, SurrealEmbeddedClient, SchemaInitializer); } public ICBDDCSurrealEmbeddedClient SurrealEmbeddedClient { get; } public ICBDDCSurrealSchemaInitializer SchemaInitializer { get; private set; } public SampleSurrealCollection Users { get; private set; } public SampleSurrealCollection TodoLists { get; private set; } public SampleSurrealReadOnlyCollection OplogEntries { get; private set; } public async Task SaveChangesAsync(CancellationToken cancellationToken = default) { await SchemaInitializer.EnsureInitializedAsync(cancellationToken); } public void Dispose() { Users.Dispose(); TodoLists.Dispose(); if (_ownsClient) SurrealEmbeddedClient.Dispose(); } private static string NormalizeDatabasePath(string databasePath) { if (string.IsNullOrWhiteSpace(databasePath)) throw new ArgumentException("Database path is required.", nameof(databasePath)); return Path.GetFullPath(databasePath); } private static string ComputeDeterministicSuffix(string value) { byte[] bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value)); return Convert.ToHexString(bytes).ToLowerInvariant()[..12]; } } public sealed class SampleSurrealSchemaInitializer : ICBDDCSurrealSchemaInitializer { private const string SampleSchemaSql = """ DEFINE TABLE OVERWRITE sample_users SCHEMALESS CHANGEFEED 7d; DEFINE TABLE OVERWRITE sample_todo_lists SCHEMALESS CHANGEFEED 7d; """; private readonly ICBDDCSurrealEmbeddedClient _client; private int _initialized; public SampleSurrealSchemaInitializer(ICBDDCSurrealEmbeddedClient client) { _client = client ?? throw new ArgumentNullException(nameof(client)); } public async Task EnsureInitializedAsync(CancellationToken cancellationToken = default) { if (Volatile.Read(ref _initialized) == 1) return; await _client.InitializeAsync(cancellationToken); await _client.RawQueryAsync(SampleSchemaSql, cancellationToken: cancellationToken); Volatile.Write(ref _initialized, 1); } } public sealed class SampleSurrealCollection : ISurrealWatchableCollection, IDisposable where TEntity : class { private readonly SurrealCollectionChangeFeed _changeFeed = new(); private readonly ISurrealDbClient _client; private readonly Func _keySelector; private readonly ICBDDCSurrealSchemaInitializer _schemaInitializer; private readonly string _tableName; public SampleSurrealCollection( string tableName, Func keySelector, ICBDDCSurrealEmbeddedClient surrealEmbeddedClient, ICBDDCSurrealSchemaInitializer schemaInitializer) { if (string.IsNullOrWhiteSpace(tableName)) throw new ArgumentException("Table name is required.", nameof(tableName)); _tableName = tableName; _keySelector = keySelector ?? throw new ArgumentNullException(nameof(keySelector)); _client = (surrealEmbeddedClient ?? throw new ArgumentNullException(nameof(surrealEmbeddedClient))).Client; _schemaInitializer = schemaInitializer ?? throw new ArgumentNullException(nameof(schemaInitializer)); } public IDisposable Subscribe(IObserver> observer) { return _changeFeed.Subscribe(observer); } public async Task InsertAsync(TEntity entity, CancellationToken cancellationToken = default) { await UpsertAsync(entity, cancellationToken); } public async Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default) { await UpsertAsync(entity, cancellationToken); } public async Task DeleteAsync(string id, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(id)) throw new ArgumentException("Document id is required.", nameof(id)); await EnsureReadyAsync(cancellationToken); await _client.Delete(RecordId.From(_tableName, id), cancellationToken); _changeFeed.PublishDelete(id); } public TEntity? FindById(string id) { return FindByIdAsync(id).GetAwaiter().GetResult(); } public async Task FindByIdAsync(string id, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(id)) throw new ArgumentException("Document id is required.", nameof(id)); await EnsureReadyAsync(cancellationToken); var record = await _client.Select>(RecordId.From(_tableName, id), cancellationToken); return record?.Entity; } public IEnumerable FindAll() { return FindAllAsync().GetAwaiter().GetResult(); } public async Task> FindAllAsync(CancellationToken cancellationToken = default) { await EnsureReadyAsync(cancellationToken); var rows = await _client.Select>(_tableName, cancellationToken); return rows? .Where(r => r.Entity != null) .Select(r => r.Entity!) .ToList() ?? []; } public IEnumerable Find(Func predicate) { ArgumentNullException.ThrowIfNull(predicate); return FindAll().Where(predicate); } public void Dispose() { _changeFeed.Dispose(); } private async Task UpsertAsync(TEntity entity, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(entity); string key = _keySelector(entity); if (string.IsNullOrWhiteSpace(key)) throw new InvalidOperationException("Entity key cannot be null or empty."); await EnsureReadyAsync(cancellationToken); await _client.Upsert, SampleEntityRecord>( RecordId.From(_tableName, key), new SampleEntityRecord { Entity = entity }, cancellationToken); _changeFeed.PublishPut(entity, key); } private async Task EnsureReadyAsync(CancellationToken cancellationToken) { await _schemaInitializer.EnsureInitializedAsync(cancellationToken); } } public sealed class SampleSurrealReadOnlyCollection where TEntity : class { private readonly ISurrealDbClient _client; private readonly ICBDDCSurrealSchemaInitializer _schemaInitializer; private readonly string _tableName; public SampleSurrealReadOnlyCollection( string tableName, ICBDDCSurrealEmbeddedClient surrealEmbeddedClient, ICBDDCSurrealSchemaInitializer schemaInitializer) { if (string.IsNullOrWhiteSpace(tableName)) throw new ArgumentException("Table name is required.", nameof(tableName)); _tableName = tableName; _client = (surrealEmbeddedClient ?? throw new ArgumentNullException(nameof(surrealEmbeddedClient))).Client; _schemaInitializer = schemaInitializer ?? throw new ArgumentNullException(nameof(schemaInitializer)); } public IEnumerable FindAll() { return FindAllAsync().GetAwaiter().GetResult(); } public async Task> FindAllAsync(CancellationToken cancellationToken = default) { await _schemaInitializer.EnsureInitializedAsync(cancellationToken); var rows = await _client.Select(_tableName, cancellationToken); return rows?.ToList() ?? []; } public IEnumerable Find(Func predicate) { ArgumentNullException.ThrowIfNull(predicate); return FindAll().Where(predicate); } } public sealed class SampleEntityRecord : Record where TEntity : class { [JsonPropertyName("entity")] public TEntity? Entity { get; set; } } public sealed class SampleOplogEntry : Record { [JsonPropertyName("collection")] public string Collection { get; set; } = ""; [JsonPropertyName("key")] public string Key { get; set; } = ""; [JsonPropertyName("operation")] public int Operation { get; set; } [JsonPropertyName("timestampNodeId")] public string TimestampNodeId { get; set; } = ""; [JsonPropertyName("timestampPhysicalTime")] public long TimestampPhysicalTime { get; set; } [JsonPropertyName("timestampLogicalCounter")] public int TimestampLogicalCounter { get; set; } [JsonPropertyName("hash")] public string Hash { get; set; } = ""; }