Add CultMesh document catalog mutations

This commit is contained in:
Meta Vens 2026-06-27 16:28:42 +01:00
commit 52ff632d70
5 changed files with 122 additions and 5 deletions

View file

@ -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<TDocument>(
schema: CultMeshDocumentSchemaDescriptor,
value: TDocument,
options?: { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string },
): Promise<void>;
public replace<TDocument>(
schema: CultMeshDocumentSchemaDescriptor,
context: CultMeshQueryContext | string,
value: TDocument,
options?: { parse?: (value: unknown) => TDocument },
): Promise<void>;
public replace<TDocument>(
schema: CultMeshDocumentSchemaDescriptor,
contextOrValue: CultMeshQueryContext | string | TDocument,
valueOrOptions?: TDocument | { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string },
maybeOptions: { parse?: (value: unknown) => TDocument } = {},
): Promise<void> {
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<TDocument>(
schema: CultMeshDocumentSchemaDescriptor,
value: TDocument,
options?: { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string },
): Promise<void>;
public submitPrediction<TDocument>(
schema: CultMeshDocumentSchemaDescriptor,
context: CultMeshQueryContext | string,
value: TDocument,
options?: { parse?: (value: unknown) => TDocument },
): Promise<void>;
public submitPrediction<TDocument>(
schema: CultMeshDocumentSchemaDescriptor,
contextOrValue: CultMeshQueryContext | string | TDocument,
valueOrOptions?: TDocument | { parse?: (value: unknown) => TDocument; context?: CultMeshQueryContext | string },
maybeOptions: { parse?: (value: unknown) => TDocument } = {},
): Promise<void> {
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<TDocument>(
schema: CultMeshDocumentSchemaDescriptor,
callback: (value: TDocument) => void,

View file

@ -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"]);

View file

@ -1177,6 +1177,34 @@ namespace GameCult.Mesh
return Document<TDocument>().Latest();
}
/// <summary>Gets whether one typed document can be replaced by CLR type or same-schema alias.</summary>
public bool CanReplace<TDocument>()
where TDocument : class
{
return TryGetDocument<TDocument>(out var document) && document.CanReplace;
}
/// <summary>Replaces one typed document by CLR type or same-schema alias.</summary>
public Task ReplaceAsync<TDocument>(TDocument value)
where TDocument : class
{
return Document<TDocument>().ReplaceAsync(value);
}
/// <summary>Gets whether one typed document can submit client predictions by CLR type or same-schema alias.</summary>
public bool CanSubmitPrediction<TDocument>()
where TDocument : class
{
return TryGetDocument<TDocument>(out var document) && document.CanSubmitPrediction;
}
/// <summary>Submits a client prediction for one typed document by CLR type or same-schema alias.</summary>
public Task SubmitPredictionAsync<TDocument>(TDocument value)
where TDocument : class
{
return Document<TDocument>().SubmitPredictionAsync(value);
}
/// <summary>Watches one typed document by CLR type or same-schema alias.</summary>
public Observable<TDocument> Watch<TDocument>()
where TDocument : class

View file

@ -178,7 +178,10 @@ portable machinery that makes it feel native.
schema version, and resolves same-schema CLR aliases by delegating to
`CultMeshDocumentHandle<TDocument>.AsSchemaAlias<TAlias>()`. Domain facades
can expose `Document<T>()`, `DocumentBySchema(...)`, `LatestAsync<T>()`, and
`Watch<T>()` without owning schema dictionaries or alias serializers.
`Watch<T>()` without owning schema dictionaries or alias serializers. Mutable
facades can also call `ReplaceAsync<T>(...)` or
`SubmitPredictionAsync<T>(...)` directly on the catalog when they only need
the schema-aliased operation and not the intermediate handle.
- `CultMeshCollectionHandle<TDocument>` 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

View file

@ -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<MeshNoteAliasDocument>().Should().BeTrue();
await catalog.ReplaceAsync(new MeshNoteAliasDocument
{
Schema = "tests.mesh_note.v1",
Text = "cache-catalog-replaced",
Revision = 3
});
cache.Get<MeshNoteDocument>(key)!.Text.Should().Be("cache-catalog-replaced");
Func<Task> act = () => alias.SubmitPredictionAsync(new MeshNoteAliasDocument
{
Schema = "tests.mesh_note.v1",
Text = "cache-predicted",
Revision = 3
Revision = 4
});
await act.Should().ThrowAsync<NotSupportedException>();
}
@ -820,10 +830,12 @@ public sealed class CultMeshStreamingTests
handle.CanReplace.Should().BeTrue();
alias.CanSubmitPrediction.Should().BeTrue();
var catalog = CultMesh.Documents(handle);
catalog.CanSubmitPrediction<MeshNoteAliasDocument>().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",