diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index 1e8b6477..bd1c6ba9 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -1010,6 +1010,68 @@ export class CultMeshDocumentCatalog { return this.document(schema, options).latest(context); } + public canReplace(schema: CultMeshDocumentSchemaDescriptor): boolean { + return this.tryDocument(schema)?.canReplace ?? false; + } + + public replace( + schema: CultMeshDocumentSchemaDescriptor, + value: TDocument, + options?: { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string }, + ): Promise; + public replace( + schema: CultMeshDocumentSchemaDescriptor, + context: CultMeshQueryContext | string, + value: TDocument, + options?: { parse?: (value: unknown) => TDocument }, + ): Promise; + public replace( + schema: CultMeshDocumentSchemaDescriptor, + contextOrValue: CultMeshQueryContext | string | TDocument, + valueOrOptions?: TDocument | { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string }, + maybeOptions: { parse?: (value: unknown) => TDocument } = {}, + ): Promise { + const hasContext = + typeof contextOrValue === "string" || isCultMeshQueryContext(contextOrValue); + const context = hasContext + ? contextOrValue as CultMeshQueryContext | string + : (valueOrOptions as { context?: CultMeshQueryContext | string } | undefined)?.context ?? "local"; + const value = hasContext ? valueOrOptions as TDocument : contextOrValue as TDocument; + const options = hasContext ? maybeOptions : valueOrOptions as { parse?: (value: unknown) => TDocument } | undefined; + return this.document(schema, options).replace(context, value); + } + + public canSubmitPrediction(schema: CultMeshDocumentSchemaDescriptor): boolean { + return this.tryDocument(schema)?.canSubmitPrediction ?? false; + } + + public submitPrediction( + schema: CultMeshDocumentSchemaDescriptor, + value: TDocument, + options?: { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string }, + ): Promise; + public submitPrediction( + schema: CultMeshDocumentSchemaDescriptor, + context: CultMeshQueryContext | string, + value: TDocument, + options?: { parse?: (value: unknown) => TDocument }, + ): Promise; + public submitPrediction( + schema: CultMeshDocumentSchemaDescriptor, + contextOrValue: CultMeshQueryContext | string | TDocument, + valueOrOptions?: TDocument | { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string }, + maybeOptions: { parse?: (value: unknown) => TDocument } = {}, + ): Promise { + const hasContext = + typeof contextOrValue === "string" || isCultMeshQueryContext(contextOrValue); + const context = hasContext + ? contextOrValue as CultMeshQueryContext | string + : (valueOrOptions as { context?: CultMeshQueryContext | string } | undefined)?.context ?? "local"; + const value = hasContext ? valueOrOptions as TDocument : contextOrValue as TDocument; + const options = hasContext ? maybeOptions : valueOrOptions as { parse?: (value: unknown) => TDocument } | undefined; + return this.document(schema, options).submitPrediction(context, value); + } + public watch( schema: CultMeshDocumentSchemaDescriptor, callback: (value: TDocument) => void, diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index b467f9ba..219e3eb5 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -104,11 +104,19 @@ test("CultMesh TS document handles hide local cache plumbing behind typed reacti assert.equal((await alias.latest()).body, "updated"); const catalog = CultMesh.documents(document); + assert.equal(catalog.canReplace(noteAliasDocument), true); + await catalog.replace(noteAliasDocument, { + noteId: "note:1", + body: "catalog-updated", + }, { + parse: value => noteAliasDocument.schema.parse(value), + context: "browser-client", + }); assert.equal( (await catalog.latest(noteAliasDocument, "browser-client", { parse: value => noteAliasDocument.schema.parse(value), })).body, - "updated", + "catalog-updated", ); assert.throws( () => document.asSchemaAlias({ schemaId: "cultmesh.other.v0" }), @@ -138,14 +146,18 @@ test("CultMesh TS document handles submit predictions through configured authori ); const verse = CultMesh.verse("starbridge", "pilot-a"); const bound = document.bind(verse); + const catalog = CultMesh.documents(document); assert.equal(document.canReplace, false); assert.equal(document.canSubmitPrediction, true); assert.equal(bound.canSubmitPrediction, true); + assert.equal(catalog.canSubmitPrediction(noteAliasDocument), true); - await bound.submitPrediction({ + await catalog.submitPrediction(noteAliasDocument, "pilot-a", { noteId: "note:prediction", body: "predicted", + }, { + parse: value => noteAliasDocument.schema.parse(value), }); assert.deepEqual(contexts, ["pilot-a:network"]); diff --git a/src/GameCult.Mesh/CultMeshPrimitives.cs b/src/GameCult.Mesh/CultMeshPrimitives.cs index 35ffa740..a35f1c24 100644 --- a/src/GameCult.Mesh/CultMeshPrimitives.cs +++ b/src/GameCult.Mesh/CultMeshPrimitives.cs @@ -1177,6 +1177,34 @@ namespace GameCult.Mesh return Document().Latest(); } + /// Gets whether one typed document can be replaced by CLR type or same-schema alias. + public bool CanReplace() + where TDocument : class + { + return TryGetDocument(out var document) && document.CanReplace; + } + + /// Replaces one typed document by CLR type or same-schema alias. + public Task ReplaceAsync(TDocument value) + where TDocument : class + { + return Document().ReplaceAsync(value); + } + + /// Gets whether one typed document can submit client predictions by CLR type or same-schema alias. + public bool CanSubmitPrediction() + where TDocument : class + { + return TryGetDocument(out var document) && document.CanSubmitPrediction; + } + + /// Submits a client prediction for one typed document by CLR type or same-schema alias. + public Task SubmitPredictionAsync(TDocument value) + where TDocument : class + { + return Document().SubmitPredictionAsync(value); + } + /// Watches one typed document by CLR type or same-schema alias. public Observable Watch() where TDocument : class diff --git a/src/GameCult.Mesh/docs/public-api.md b/src/GameCult.Mesh/docs/public-api.md index 2fd2ae9d..d56a5a88 100644 --- a/src/GameCult.Mesh/docs/public-api.md +++ b/src/GameCult.Mesh/docs/public-api.md @@ -178,7 +178,10 @@ portable machinery that makes it feel native. schema version, and resolves same-schema CLR aliases by delegating to `CultMeshDocumentHandle.AsSchemaAlias()`. Domain facades can expose `Document()`, `DocumentBySchema(...)`, `LatestAsync()`, and - `Watch()` without owning schema dictionaries or alias serializers. + `Watch()` without owning schema dictionaries or alias serializers. Mutable + facades can also call `ReplaceAsync(...)` or + `SubmitPredictionAsync(...)` directly on the catalog when they only need + the schema-aliased operation and not the intermediate handle. - `CultMeshCollectionHandle` is the typed reactive edge for multi-record state. It exposes one `LatestAsync()` collection snapshot and a `WatchChanges()` stream while hiding whether the backing source is an diff --git a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs index 29f675dd..e4856572 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs @@ -775,11 +775,21 @@ public sealed class CultMeshStreamingTests observed.Text.Should().Be("cache-replaced"); observed.Revision.Should().Be(2); + var catalog = CultMesh.Documents(handle); + catalog.CanReplace().Should().BeTrue(); + await catalog.ReplaceAsync(new MeshNoteAliasDocument + { + Schema = "tests.mesh_note.v1", + Text = "cache-catalog-replaced", + Revision = 3 + }); + cache.Get(key)!.Text.Should().Be("cache-catalog-replaced"); + Func act = () => alias.SubmitPredictionAsync(new MeshNoteAliasDocument { Schema = "tests.mesh_note.v1", Text = "cache-predicted", - Revision = 3 + Revision = 4 }); await act.Should().ThrowAsync(); } @@ -820,10 +830,12 @@ public sealed class CultMeshStreamingTests handle.CanReplace.Should().BeTrue(); alias.CanSubmitPrediction.Should().BeTrue(); + var catalog = CultMesh.Documents(handle); + catalog.CanSubmitPrediction().Should().BeTrue(); MeshNoteDocument observed = null!; using var subscription = handle.Watch(value => observed = value); - await alias.SubmitPredictionAsync(new MeshNoteAliasDocument + await catalog.SubmitPredictionAsync(new MeshNoteAliasDocument { Schema = "tests.mesh_note.v1", Text = "predicted-thermal",