Add C# store document handles
This commit is contained in:
parent
e905aa7a0c
commit
a6537a2d93
3 changed files with 225 additions and 0 deletions
|
|
@ -140,6 +140,27 @@ namespace GameCult.Mesh
|
|||
public Action<Client>? ConfigureClient { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for opening one typed document from a CultCache backing-store publication.
|
||||
/// </summary>
|
||||
public sealed class CultMeshStoreDocumentOptions
|
||||
{
|
||||
/// <summary>Gets or sets the semantic document id. Defaults to the record key.</summary>
|
||||
public string? DocumentId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the source id advertised by diagnostics. Defaults to the record key.</summary>
|
||||
public string? SourceId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the route hint for the resulting handle.</summary>
|
||||
public CultMeshRouteHint? RouteHint { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the polling interval for watch fallback.</summary>
|
||||
public TimeSpan PollInterval { get; set; } = TimeSpan.FromMilliseconds(250);
|
||||
|
||||
/// <summary>Gets or sets the document registry used to resolve persisted schema aliases.</summary>
|
||||
public CultDocumentRegistry? Registry { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A locally hosted CultMesh node over CultCache, CultNet transport, and the mesh database facade.
|
||||
/// </summary>
|
||||
|
|
@ -1144,6 +1165,106 @@ namespace GameCult.Mesh
|
|||
routeHint);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a read-only typed document handle over one CultCache backing-store publication.
|
||||
/// </summary>
|
||||
public static CultMeshDocumentHandle<TDocument> DocumentFromStore<TDocument>(
|
||||
CacheBackingStore store,
|
||||
CultRecordKey key,
|
||||
CultMeshVerseContext context,
|
||||
CultMeshStoreDocumentOptions? options = null)
|
||||
where TDocument : class
|
||||
{
|
||||
if (store == null) throw new ArgumentNullException(nameof(store));
|
||||
if (context == null) throw new ArgumentNullException(nameof(context));
|
||||
|
||||
var resolvedOptions = options ?? new CultMeshStoreDocumentOptions();
|
||||
var cache = new CultCache(resolvedOptions.Registry ?? CultDocumentRegistry.Shared);
|
||||
cache.AddBackingStore(store);
|
||||
|
||||
var descriptor = cache.Registry.GetRequired<TDocument>();
|
||||
var documentId = ResolveDocumentId(resolvedOptions.DocumentId, key);
|
||||
var route = resolvedOptions.RouteHint ?? new CultMeshRouteHint(CultMeshLocalityKind.InProcess, "CultCache backing store");
|
||||
var sources = new[]
|
||||
{
|
||||
ProjectionSource(
|
||||
string.IsNullOrWhiteSpace(resolvedOptions.SourceId) ? key.Value : resolvedOptions.SourceId!,
|
||||
descriptor.SchemaId,
|
||||
"CultCache backing store")
|
||||
};
|
||||
Func<CultMeshQueryContext, Task<TDocument>> latest = async _ =>
|
||||
{
|
||||
await cache.PullAllBackingStoresAsync().ConfigureAwait(false);
|
||||
return ReadRequired<TDocument>(cache, key);
|
||||
};
|
||||
var watch = PollingQueryWatcher<CultMeshDocumentQueryParameters, TDocument>(
|
||||
async (_parameters, queryContext) => await latest(queryContext).ConfigureAwait(false),
|
||||
new CultMeshPollingWatchOptions<TDocument>(resolvedOptions.PollInterval));
|
||||
|
||||
return Document<TDocument>(
|
||||
documentId,
|
||||
context,
|
||||
latest,
|
||||
queryContext => watch(CultMeshDocumentQueryParameters.Empty, queryContext),
|
||||
sources,
|
||||
route);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a read-only typed document handle over one CultCache backing-store publication.
|
||||
/// </summary>
|
||||
public static CultMeshDocumentHandle<TDocument> DocumentFromStore<TDocument>(
|
||||
CacheBackingStore store,
|
||||
CultRecordKey key,
|
||||
CultMeshVerse verse,
|
||||
CultMeshStoreDocumentOptions? options = null)
|
||||
where TDocument : class
|
||||
{
|
||||
if (verse == null) throw new ArgumentNullException(nameof(verse));
|
||||
return DocumentFromStore<TDocument>(store, key, verse.Context, options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a read-only typed document handle over one single-file MessagePack CultCache publication.
|
||||
/// </summary>
|
||||
public static CultMeshDocumentHandle<TDocument> DocumentFromSingleFile<TDocument>(
|
||||
string path,
|
||||
CultRecordKey key,
|
||||
CultMeshVerseContext context,
|
||||
CultMeshStoreDocumentOptions? options = null)
|
||||
where TDocument : class
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("Value must be non-empty.", nameof(path));
|
||||
var resolvedOptions = options ?? new CultMeshStoreDocumentOptions();
|
||||
var fileOptions = new CultMeshStoreDocumentOptions
|
||||
{
|
||||
DocumentId = resolvedOptions.DocumentId,
|
||||
SourceId = resolvedOptions.SourceId,
|
||||
RouteHint = resolvedOptions.RouteHint ?? new CultMeshRouteHint(CultMeshLocalityKind.SharedMemory, path),
|
||||
PollInterval = resolvedOptions.PollInterval,
|
||||
Registry = resolvedOptions.Registry
|
||||
};
|
||||
return DocumentFromStore<TDocument>(
|
||||
new SingleFileMessagePackBackingStore(path),
|
||||
key,
|
||||
context,
|
||||
fileOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a read-only typed document handle over one single-file MessagePack CultCache publication.
|
||||
/// </summary>
|
||||
public static CultMeshDocumentHandle<TDocument> DocumentFromSingleFile<TDocument>(
|
||||
string path,
|
||||
CultRecordKey key,
|
||||
CultMeshVerse verse,
|
||||
CultMeshStoreDocumentOptions? options = null)
|
||||
where TDocument : class
|
||||
{
|
||||
if (verse == null) throw new ArgumentNullException(nameof(verse));
|
||||
return DocumentFromSingleFile<TDocument>(path, key, verse.Context, options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a typed document handle directly over one distributed CultNet database record.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -166,6 +166,23 @@ using var sub = input.Watch()
|
|||
});
|
||||
```
|
||||
|
||||
Read-only CultCache publications can be lifted into the same document handle
|
||||
shape without local store plumbing:
|
||||
|
||||
```csharp
|
||||
var health = CultMesh.DocumentFromSingleFile<DaemonHealth>(
|
||||
"daemon-publication.ccmp",
|
||||
new CultRecordKey("daemon:aetheria.health.v1"),
|
||||
verse);
|
||||
|
||||
using var healthSub = health.Watch().Subscribe(RenderHealth);
|
||||
var latestHealth = await health.LatestAsync();
|
||||
```
|
||||
|
||||
`DocumentFromStore<T>()` accepts any `CacheBackingStore`; both helpers pull,
|
||||
decode, resolve persisted schema metadata, and poll for updates behind the
|
||||
`CultMeshDocumentHandle<T>` surface.
|
||||
|
||||
## Game Session
|
||||
|
||||
`CultMeshGameSession` wires the common gameplay loop: local catalogs,
|
||||
|
|
|
|||
|
|
@ -849,6 +849,63 @@ public sealed class CultMeshStreamingTests
|
|||
(await handle.LatestAsync()).Text.Should().Be("predicted-thermal");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DocumentHandle_ReadsSchemaPublicationsFromSingleFileStores()
|
||||
{
|
||||
using var directory = new TemporaryDirectory();
|
||||
var filePath = Path.Combine(directory.Path, "publication.ccmp");
|
||||
var key = new CultRecordKey("mesh-note:publication");
|
||||
|
||||
var writerCache = new CultCache();
|
||||
writerCache.AddBackingStore(new SingleFileMessagePackBackingStore(filePath));
|
||||
await writerCache.UpsertAsync(typeof(MeshPublicationNoteDocument), new MeshPublicationNoteDocument
|
||||
{
|
||||
Schema = "tests.mesh_publication_note.v1",
|
||||
Text = "published",
|
||||
Revision = 1
|
||||
}, key);
|
||||
writerCache.FlushAllBackingStores();
|
||||
File.Exists(filePath).Should().BeTrue();
|
||||
|
||||
var directReadCache = new CultCache();
|
||||
directReadCache.AddBackingStore(new SingleFileMessagePackBackingStore(filePath));
|
||||
await directReadCache.PullAllBackingStoresAsync();
|
||||
directReadCache.Get<MeshPublicationNoteDocument>(key)!.Text.Should().Be("published");
|
||||
|
||||
var handle = CultMesh.DocumentFromSingleFile<MeshPublicationNoteDocument>(
|
||||
filePath,
|
||||
key,
|
||||
CultMesh.Verse("starbridge", "unity-pilot"),
|
||||
new CultMeshStoreDocumentOptions
|
||||
{
|
||||
DocumentId = "daemon:tests.mesh_note.latest",
|
||||
SourceId = "daemon:tests.mesh_note.latest.v1",
|
||||
PollInterval = TimeSpan.FromMilliseconds(10)
|
||||
});
|
||||
var observed = new List<MeshPublicationNoteDocument>();
|
||||
using var subscription = handle.Watch(value => observed.Add(value));
|
||||
|
||||
(await handle.LatestAsync()).Text.Should().Be("published");
|
||||
handle.CanReplace.Should().BeFalse();
|
||||
handle.CanSubmitPrediction.Should().BeFalse();
|
||||
handle.DocumentId.Should().Be("daemon:tests.mesh_note.latest");
|
||||
handle.RouteHint.Kind.Should().Be(CultMeshLocalityKind.SharedMemory);
|
||||
handle.Sources.Should().ContainSingle().Which.SourceId.Should().Be("daemon:tests.mesh_note.latest.v1");
|
||||
|
||||
var republisher = new CultCache();
|
||||
republisher.AddBackingStore(new SingleFileMessagePackBackingStore(filePath));
|
||||
await republisher.UpsertAsync(typeof(MeshPublicationNoteDocument), new MeshPublicationNoteDocument
|
||||
{
|
||||
Schema = "tests.mesh_publication_note.v1",
|
||||
Text = "republished",
|
||||
Revision = 2
|
||||
}, key);
|
||||
republisher.FlushAllBackingStores();
|
||||
|
||||
await WaitForAsync(() => observed.Any(value => value.Text == "republished"));
|
||||
(await handle.LatestAsync()).Revision.Should().Be(2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DocumentHandle_ReadsRemotePeerSnapshotsAsTypedDocuments()
|
||||
{
|
||||
|
|
@ -1393,6 +1450,22 @@ public sealed class CultMeshStreamingTests
|
|||
return catalog;
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> predicate)
|
||||
{
|
||||
var deadline = DateTimeOffset.UtcNow.AddSeconds(2);
|
||||
while (DateTimeOffset.UtcNow < deadline)
|
||||
{
|
||||
if (predicate())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(20);
|
||||
}
|
||||
|
||||
predicate().Should().BeTrue();
|
||||
}
|
||||
|
||||
private sealed class TemporaryDirectory : IDisposable
|
||||
{
|
||||
public TemporaryDirectory()
|
||||
|
|
@ -1470,6 +1543,20 @@ public sealed class CultMeshStreamingTests
|
|||
public int Revision { get; set; }
|
||||
}
|
||||
|
||||
[CultDocument("tests.mesh_publication_note", "tests.mesh_publication_note.v1")]
|
||||
[MessagePackObject(AllowPrivate = true)]
|
||||
internal sealed class MeshPublicationNoteDocument
|
||||
{
|
||||
[Key(0)]
|
||||
public string Schema { get; set; } = string.Empty;
|
||||
|
||||
[Key(1)]
|
||||
public string Text { get; set; } = string.Empty;
|
||||
|
||||
[Key(2)]
|
||||
public int Revision { get; set; }
|
||||
}
|
||||
|
||||
[CultDocument("tests.mesh_other", "tests.mesh_other.v1")]
|
||||
[MessagePackObject(AllowPrivate = true)]
|
||||
internal sealed class MeshOtherDocument
|
||||
|
|
|
|||
Loading…
Reference in a new issue