Replace BLite with Surreal embedded persistence
All checks were successful
NuGet Package Publish / nuget (push) Successful in 1m21s

This commit is contained in:
Joseph Doherty
2026-02-22 05:21:53 -05:00
parent 7ebc2cb567
commit 9c2a77dc3c
56 changed files with 6613 additions and 3177 deletions

View File

@@ -1,50 +1,299 @@
using BLite.Core.Collections;
using BLite.Core.Metadata;
using BLite.Core.Storage;
using ZB.MOM.WW.CBDDC.Persistence.BLite;
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 : CBDDCDocumentDbContext
public class SampleDbContext : IDisposable
{
/// <summary>
/// Initializes a new instance of the SampleDbContext class using the specified database file path.
/// </summary>
/// <param name="databasePath">The file system path to the database file. Cannot be null or empty.</param>
public SampleDbContext(string databasePath) : base(databasePath)
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<User>(UsersTable, u => u.Id, SurrealEmbeddedClient, SchemaInitializer);
TodoLists = new SampleSurrealCollection<TodoList>(TodoListsTable, t => t.Id, SurrealEmbeddedClient, SchemaInitializer);
OplogEntries = new SampleSurrealReadOnlyCollection<SampleOplogEntry>(
CBDDCSurrealSchemaNames.OplogEntriesTable,
SurrealEmbeddedClient,
SchemaInitializer);
}
/// <summary>
/// Initializes a new instance of the SampleDbContext class using the specified database file path and page file
/// configuration.
/// </summary>
/// <param name="databasePath">The file system path to the database file. Cannot be null or empty.</param>
/// <param name="config">The configuration settings for the page file. Cannot be null.</param>
public SampleDbContext(string databasePath, PageFileConfig config) : base(databasePath, config)
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<User>(UsersTable, u => u.Id, SurrealEmbeddedClient, SchemaInitializer);
TodoLists = new SampleSurrealCollection<TodoList>(TodoListsTable, t => t.Id, SurrealEmbeddedClient, SchemaInitializer);
OplogEntries = new SampleSurrealReadOnlyCollection<SampleOplogEntry>(
CBDDCSurrealSchemaNames.OplogEntriesTable,
SurrealEmbeddedClient,
SchemaInitializer);
}
/// <summary>
/// Gets the users collection.
/// </summary>
public DocumentCollection<string, User> Users { get; private set; } = null!;
public ICBDDCSurrealEmbeddedClient SurrealEmbeddedClient { get; }
/// <summary>
/// Gets the todo lists collection.
/// </summary>
public DocumentCollection<string, TodoList> TodoLists { get; private set; } = null!;
public ICBDDCSurrealSchemaInitializer SchemaInitializer { get; private set; }
/// <inheritdoc />
protected override void OnModelCreating(ModelBuilder modelBuilder)
public SampleSurrealCollection<User> Users { get; private set; }
public SampleSurrealCollection<TodoList> TodoLists { get; private set; }
public SampleSurrealReadOnlyCollection<SampleOplogEntry> OplogEntries { get; private set; }
public async Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<User>()
.ToCollection("Users")
.HasKey(u => u.Id);
modelBuilder.Entity<TodoList>()
.ToCollection("TodoLists")
.HasKey(t => t.Id);
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<TEntity> : ISurrealWatchableCollection<TEntity>, IDisposable
where TEntity : class
{
private readonly SurrealCollectionChangeFeed<TEntity> _changeFeed = new();
private readonly ISurrealDbClient _client;
private readonly Func<TEntity, string> _keySelector;
private readonly ICBDDCSurrealSchemaInitializer _schemaInitializer;
private readonly string _tableName;
public SampleSurrealCollection(
string tableName,
Func<TEntity, string> 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<SurrealCollectionChange<TEntity>> 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<TEntity?> 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<SampleEntityRecord<TEntity>>(RecordId.From(_tableName, id), cancellationToken);
return record?.Entity;
}
public IEnumerable<TEntity> FindAll()
{
return FindAllAsync().GetAwaiter().GetResult();
}
public async Task<IReadOnlyList<TEntity>> FindAllAsync(CancellationToken cancellationToken = default)
{
await EnsureReadyAsync(cancellationToken);
var rows = await _client.Select<SampleEntityRecord<TEntity>>(_tableName, cancellationToken);
return rows?
.Where(r => r.Entity != null)
.Select(r => r.Entity!)
.ToList()
?? [];
}
public IEnumerable<TEntity> Find(Func<TEntity, bool> 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<TEntity>, SampleEntityRecord<TEntity>>(
RecordId.From(_tableName, key),
new SampleEntityRecord<TEntity> { Entity = entity },
cancellationToken);
_changeFeed.PublishPut(entity, key);
}
private async Task EnsureReadyAsync(CancellationToken cancellationToken)
{
await _schemaInitializer.EnsureInitializedAsync(cancellationToken);
}
}
public sealed class SampleSurrealReadOnlyCollection<TEntity>
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<TEntity> FindAll()
{
return FindAllAsync().GetAwaiter().GetResult();
}
public async Task<IReadOnlyList<TEntity>> FindAllAsync(CancellationToken cancellationToken = default)
{
await _schemaInitializer.EnsureInitializedAsync(cancellationToken);
var rows = await _client.Select<TEntity>(_tableName, cancellationToken);
return rows?.ToList() ?? [];
}
public IEnumerable<TEntity> Find(Func<TEntity, bool> predicate)
{
ArgumentNullException.ThrowIfNull(predicate);
return FindAll().Where(predicate);
}
}
public sealed class SampleEntityRecord<TEntity> : 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; } = "";
}