424 lines
16 KiB
C#
Executable File
424 lines
16 KiB
C#
Executable File
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 const string LogsTable = "sample_logs";
|
|
private const string TimeseriesTable = "sample_timeseries";
|
|
|
|
private readonly bool _ownsClient;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="SampleDbContext"/> class.
|
|
/// </summary>
|
|
/// <param name="surrealEmbeddedClient">The embedded SurrealDB client.</param>
|
|
/// <param name="schemaInitializer">The schema initializer.</param>
|
|
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);
|
|
Logs = new SampleSurrealCollection<TelemetryLogEntry>(LogsTable, e => e.Id, SurrealEmbeddedClient, SchemaInitializer);
|
|
Timeseries = new SampleSurrealCollection<TimeseriesPoint>(TimeseriesTable, p => p.Id, SurrealEmbeddedClient, SchemaInitializer);
|
|
OplogEntries = new SampleSurrealReadOnlyCollection<SampleOplogEntry>(
|
|
CBDDCSurrealSchemaNames.OplogEntriesTable,
|
|
SurrealEmbeddedClient,
|
|
SchemaInitializer);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="SampleDbContext"/> class.
|
|
/// </summary>
|
|
/// <param name="databasePath">The database path used for the embedded store.</param>
|
|
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);
|
|
Logs = new SampleSurrealCollection<TelemetryLogEntry>(LogsTable, e => e.Id, SurrealEmbeddedClient, SchemaInitializer);
|
|
Timeseries = new SampleSurrealCollection<TimeseriesPoint>(TimeseriesTable, p => p.Id, SurrealEmbeddedClient, SchemaInitializer);
|
|
OplogEntries = new SampleSurrealReadOnlyCollection<SampleOplogEntry>(
|
|
CBDDCSurrealSchemaNames.OplogEntriesTable,
|
|
SurrealEmbeddedClient,
|
|
SchemaInitializer);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the embedded SurrealDB client.
|
|
/// </summary>
|
|
public ICBDDCSurrealEmbeddedClient SurrealEmbeddedClient { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the schema initializer.
|
|
/// </summary>
|
|
public ICBDDCSurrealSchemaInitializer SchemaInitializer { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Gets the users collection.
|
|
/// </summary>
|
|
public SampleSurrealCollection<User> Users { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Gets the todo lists collection.
|
|
/// </summary>
|
|
public SampleSurrealCollection<TodoList> TodoLists { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Gets the operation log entries collection.
|
|
/// </summary>
|
|
public SampleSurrealReadOnlyCollection<SampleOplogEntry> OplogEntries { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Gets the append-only telemetry logs collection.
|
|
/// </summary>
|
|
public SampleSurrealCollection<TelemetryLogEntry> Logs { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Gets the append-only timeseries collection.
|
|
/// </summary>
|
|
public SampleSurrealCollection<TimeseriesPoint> Timeseries { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Ensures schema changes are applied before persisting updates.
|
|
/// </summary>
|
|
/// <param name="cancellationToken">A cancellation token.</param>
|
|
public async Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
await SchemaInitializer.EnsureInitializedAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void Dispose()
|
|
{
|
|
Users.Dispose();
|
|
TodoLists.Dispose();
|
|
Logs.Dispose();
|
|
Timeseries.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;
|
|
DEFINE TABLE OVERWRITE sample_logs SCHEMALESS CHANGEFEED 7d;
|
|
DEFINE TABLE OVERWRITE sample_timeseries SCHEMALESS CHANGEFEED 7d;
|
|
""";
|
|
private readonly ICBDDCSurrealEmbeddedClient _client;
|
|
private int _initialized;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="SampleSurrealSchemaInitializer"/> class.
|
|
/// </summary>
|
|
/// <param name="client">The embedded SurrealDB client.</param>
|
|
public SampleSurrealSchemaInitializer(ICBDDCSurrealEmbeddedClient client)
|
|
{
|
|
_client = client ?? throw new ArgumentNullException(nameof(client));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="SampleSurrealCollection{TEntity}"/> class.
|
|
/// </summary>
|
|
/// <param name="tableName">The backing table name.</param>
|
|
/// <param name="keySelector">The key selector for entities.</param>
|
|
/// <param name="surrealEmbeddedClient">The embedded SurrealDB client.</param>
|
|
/// <param name="schemaInitializer">The schema initializer.</param>
|
|
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));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public IDisposable Subscribe(IObserver<SurrealCollectionChange<TEntity>> observer)
|
|
{
|
|
return _changeFeed.Subscribe(observer);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task InsertAsync(TEntity entity, CancellationToken cancellationToken = default)
|
|
{
|
|
await UpsertAsync(entity, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default)
|
|
{
|
|
await UpsertAsync(entity, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Finds an entity by identifier.
|
|
/// </summary>
|
|
/// <param name="id">The entity identifier.</param>
|
|
/// <returns>The matching entity when found; otherwise <see langword="null"/>.</returns>
|
|
public TEntity? FindById(string id)
|
|
{
|
|
return FindByIdAsync(id).GetAwaiter().GetResult();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Finds an entity by identifier asynchronously.
|
|
/// </summary>
|
|
/// <param name="id">The entity identifier.</param>
|
|
/// <param name="cancellationToken">A cancellation token.</param>
|
|
/// <returns>The matching entity when found; otherwise <see langword="null"/>.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public IEnumerable<TEntity> FindAll()
|
|
{
|
|
return FindAllAsync().GetAwaiter().GetResult();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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()
|
|
?? [];
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public IEnumerable<TEntity> Find(Func<TEntity, bool> predicate)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(predicate);
|
|
return FindAll().Where(predicate);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="SampleSurrealReadOnlyCollection{TEntity}"/> class.
|
|
/// </summary>
|
|
/// <param name="tableName">The backing table name.</param>
|
|
/// <param name="surrealEmbeddedClient">The embedded SurrealDB client.</param>
|
|
/// <param name="schemaInitializer">The schema initializer.</param>
|
|
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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns all entities from the collection.
|
|
/// </summary>
|
|
/// <returns>The entities in the collection.</returns>
|
|
public IEnumerable<TEntity> FindAll()
|
|
{
|
|
return FindAllAsync().GetAwaiter().GetResult();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns all entities from the collection asynchronously.
|
|
/// </summary>
|
|
/// <param name="cancellationToken">A cancellation token.</param>
|
|
/// <returns>The entities in the collection.</returns>
|
|
public async Task<IReadOnlyList<TEntity>> FindAllAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
await _schemaInitializer.EnsureInitializedAsync(cancellationToken);
|
|
var rows = await _client.Select<TEntity>(_tableName, cancellationToken);
|
|
return rows?.ToList() ?? [];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns entities that match the provided predicate.
|
|
/// </summary>
|
|
/// <param name="predicate">The predicate used to filter entities.</param>
|
|
/// <returns>The entities that satisfy the predicate.</returns>
|
|
public IEnumerable<TEntity> Find(Func<TEntity, bool> predicate)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(predicate);
|
|
return FindAll().Where(predicate);
|
|
}
|
|
}
|
|
|
|
public sealed class SampleEntityRecord<TEntity> : Record
|
|
where TEntity : class
|
|
{
|
|
/// <summary>
|
|
/// Gets or sets the stored entity payload.
|
|
/// </summary>
|
|
[JsonPropertyName("entity")]
|
|
public TEntity? Entity { get; set; }
|
|
}
|
|
|
|
public sealed class SampleOplogEntry : Record
|
|
{
|
|
/// <summary>
|
|
/// Gets or sets the collection name.
|
|
/// </summary>
|
|
[JsonPropertyName("collection")]
|
|
public string Collection { get; set; } = "";
|
|
|
|
/// <summary>
|
|
/// Gets or sets the entity key.
|
|
/// </summary>
|
|
[JsonPropertyName("key")]
|
|
public string Key { get; set; } = "";
|
|
|
|
/// <summary>
|
|
/// Gets or sets the operation code.
|
|
/// </summary>
|
|
[JsonPropertyName("operation")]
|
|
public int Operation { get; set; }
|
|
|
|
/// <summary>
|
|
/// Gets or sets the node identifier portion of the timestamp.
|
|
/// </summary>
|
|
[JsonPropertyName("timestampNodeId")]
|
|
public string TimestampNodeId { get; set; } = "";
|
|
|
|
/// <summary>
|
|
/// Gets or sets the physical time portion of the timestamp.
|
|
/// </summary>
|
|
[JsonPropertyName("timestampPhysicalTime")]
|
|
public long TimestampPhysicalTime { get; set; }
|
|
|
|
/// <summary>
|
|
/// Gets or sets the logical counter portion of the timestamp.
|
|
/// </summary>
|
|
[JsonPropertyName("timestampLogicalCounter")]
|
|
public int TimestampLogicalCounter { get; set; }
|
|
|
|
/// <summary>
|
|
/// Gets or sets the hash for the operation entry.
|
|
/// </summary>
|
|
[JsonPropertyName("hash")]
|
|
public string Hash { get; set; } = "";
|
|
}
|