CultMesh TS: a catalog lookup infers its value type from the definition

The deleted parse option was the only thing that typed a catalog lookup, so
every caller had to name the value type. Catalog methods now take the schema
argument as a type parameter: a CultCache document definition yields its own
value type, an explicit type argument still wins, and a bare schema
descriptor yields unknown. CultMeshCatalogValue names that rule.

- Document catalog: tryDocument, document, latest, replace,
  submitPrediction, authoritativeWriter, predictionWriter, watch.
  Collection catalog: tryCollection, collection, latest, watchChanges.
- The two copies of the (value, options) / (context, value) argument split
  collapse into cultMeshCatalogWriteArguments, and the typed lookups share one
  private #require per catalog.
- Tests: the three reads that named their type infer it again. A type-level
  test pins inference for reads, rows, watches, writes and writers with
  @ts-expect-error on wrong assignments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 17:36:18 +02:00
commit 43ed9396d3
2 changed files with 133 additions and 81 deletions

View file

@ -201,6 +201,14 @@ export type CultMeshCollectionWatcher<TDocument> = (
callback: (change: CultMeshCollectionChange<TDocument>) => void,
) => CultMeshUnsubscribe;
/**
* The value type a catalog lookup yields. An explicit type argument wins; otherwise a CultCache document definition
* names its own value type, and a bare schema descriptor yields `unknown`.
*/
export type CultMeshCatalogValue<TDocument, TSchema> = [TDocument] extends [never]
? TSchema extends AnyCultCacheDocumentDefinition ? CultCacheDocumentValue<TSchema> : unknown
: TDocument;
export interface CultMeshDocumentSchemaDescriptor {
readonly type?: string;
readonly schemaId?: string;
@ -1479,9 +1487,9 @@ export class CultMeshDocumentCatalog {
return this;
}
public tryDocument<TDocument>(
schema: CultMeshDocumentSchemaDescriptor,
): CultMeshDocumentHandle<TDocument> | undefined {
public tryDocument<TDocument = never, TSchema extends CultMeshDocumentSchemaDescriptor = CultMeshDocumentSchemaDescriptor>(
schema: TSchema,
): CultMeshDocumentHandle<CultMeshCatalogValue<TDocument, TSchema>> | undefined {
const descriptor = normalizeCultMeshDocumentSchema(schema);
// One type owns a schema, so a typed lookup is answered by that type's handle or not at all.
if (descriptor.type) {
@ -1492,99 +1500,93 @@ export class CultMeshDocumentCatalog {
(key ? this.#bySchemaNameVersion.get(key) : undefined);
}
public document<TDocument>(
schema: CultMeshDocumentSchemaDescriptor,
): CultMeshDocumentHandle<TDocument> {
const document = this.tryDocument<TDocument>(schema);
if (!document) {
throw new Error(`Document catalog has no document for ${cultMeshSchemaLabel(schema)}.`);
}
return document;
public document<TDocument = never, TSchema extends CultMeshDocumentSchemaDescriptor = CultMeshDocumentSchemaDescriptor>(
schema: TSchema,
): CultMeshDocumentHandle<CultMeshCatalogValue<TDocument, TSchema>> {
return this.#require(schema);
}
public latest<TDocument>(
schema: CultMeshDocumentSchemaDescriptor,
public latest<TDocument = never, TSchema extends CultMeshDocumentSchemaDescriptor = CultMeshDocumentSchemaDescriptor>(
schema: TSchema,
context: CultMeshQueryContext | string = "local",
): Promise<TDocument> {
return this.document<TDocument>(schema).latest(context);
): Promise<CultMeshCatalogValue<TDocument, TSchema>> {
return this.#require(schema).latest(context);
}
public canReplace(schema: CultMeshDocumentSchemaDescriptor): boolean {
return this.tryDocument(schema)?.canReplace ?? false;
}
public replace<TDocument>(
schema: CultMeshDocumentSchemaDescriptor,
value: TDocument,
public replace<TDocument = never, TSchema extends CultMeshDocumentSchemaDescriptor = CultMeshDocumentSchemaDescriptor>(
schema: TSchema,
value: CultMeshCatalogValue<TDocument, TSchema>,
options?: { context?: CultMeshQueryContext | string },
): Promise<void>;
public replace<TDocument>(
schema: CultMeshDocumentSchemaDescriptor,
public replace<TDocument = never, TSchema extends CultMeshDocumentSchemaDescriptor = CultMeshDocumentSchemaDescriptor>(
schema: TSchema,
context: CultMeshQueryContext | string,
value: TDocument,
value: CultMeshCatalogValue<TDocument, TSchema>,
): Promise<void>;
public replace<TDocument>(
public replace(
schema: CultMeshDocumentSchemaDescriptor,
contextOrValue: CultMeshQueryContext | string | TDocument,
valueOrOptions?: TDocument | { context?: CultMeshQueryContext | string },
contextOrValue: unknown,
valueOrOptions?: unknown,
): 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;
return this.document<TDocument>(schema).replace(context, value);
const [context, value] = cultMeshCatalogWriteArguments(contextOrValue, valueOrOptions);
return this.#require(schema).replace(context, value);
}
public canSubmitPrediction(schema: CultMeshDocumentSchemaDescriptor): boolean {
return this.tryDocument(schema)?.canSubmitPrediction ?? false;
}
public submitPrediction<TDocument>(
schema: CultMeshDocumentSchemaDescriptor,
value: TDocument,
public submitPrediction<TDocument = never, TSchema extends CultMeshDocumentSchemaDescriptor = CultMeshDocumentSchemaDescriptor>(
schema: TSchema,
value: CultMeshCatalogValue<TDocument, TSchema>,
options?: { context?: CultMeshQueryContext | string },
): Promise<void>;
public submitPrediction<TDocument>(
schema: CultMeshDocumentSchemaDescriptor,
public submitPrediction<TDocument = never, TSchema extends CultMeshDocumentSchemaDescriptor = CultMeshDocumentSchemaDescriptor>(
schema: TSchema,
context: CultMeshQueryContext | string,
value: TDocument,
value: CultMeshCatalogValue<TDocument, TSchema>,
): Promise<void>;
public submitPrediction<TDocument>(
public submitPrediction(
schema: CultMeshDocumentSchemaDescriptor,
contextOrValue: CultMeshQueryContext | string | TDocument,
valueOrOptions?: TDocument | { context?: CultMeshQueryContext | string },
contextOrValue: unknown,
valueOrOptions?: unknown,
): 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;
return this.document<TDocument>(schema).submitPrediction(context, value);
const [context, value] = cultMeshCatalogWriteArguments(contextOrValue, valueOrOptions);
return this.#require(schema).submitPrediction(context, value);
}
public authoritativeWriter<TDocument extends object>(
schema: CultMeshDocumentSchemaDescriptor,
public authoritativeWriter<TDocument extends object = never, TSchema extends CultMeshDocumentSchemaDescriptor = CultMeshDocumentSchemaDescriptor>(
schema: TSchema,
options: { context?: CultMeshQueryContext | string } = {},
): CultMeshDocumentWriter<TDocument> {
return this.document<TDocument>(schema).authoritativeWriter(options.context ?? "local");
): CultMeshDocumentWriter<CultMeshCatalogValue<TDocument, TSchema> & object> {
return this.#require(schema).authoritativeWriter(options.context ?? "local");
}
public predictionWriter<TDocument extends object>(
schema: CultMeshDocumentSchemaDescriptor,
public predictionWriter<TDocument extends object = never, TSchema extends CultMeshDocumentSchemaDescriptor = CultMeshDocumentSchemaDescriptor>(
schema: TSchema,
options: { context?: CultMeshQueryContext | string } = {},
): CultMeshDocumentWriter<TDocument> {
return this.document<TDocument>(schema).predictionWriter(options.context ?? "local");
): CultMeshDocumentWriter<CultMeshCatalogValue<TDocument, TSchema> & object> {
return this.#require(schema).predictionWriter(options.context ?? "local");
}
public watch<TDocument>(
schema: CultMeshDocumentSchemaDescriptor,
callback: (value: TDocument) => void,
public watch<TDocument = never, TSchema extends CultMeshDocumentSchemaDescriptor = CultMeshDocumentSchemaDescriptor>(
schema: TSchema,
callback: (value: CultMeshCatalogValue<TDocument, TSchema>) => void,
options: { context?: CultMeshQueryContext | string } = {},
): CultMeshUnsubscribe {
return this.document<TDocument>(schema).watch(options.context ?? "local", callback);
return this.#require(schema).watch(options.context ?? "local", callback);
}
#require(schema: CultMeshDocumentSchemaDescriptor): CultMeshDocumentHandle<any> {
const document = this.tryDocument(schema);
if (!document) {
throw new Error(`Document catalog has no document for ${cultMeshSchemaLabel(schema)}.`);
}
return document;
}
}
@ -1696,9 +1698,9 @@ export class CultMeshCollectionCatalog {
return this;
}
public tryCollection<TDocument>(
schema: CultMeshDocumentSchemaDescriptor,
): CultMeshCollectionHandle<TDocument> | undefined {
public tryCollection<TDocument = never, TSchema extends CultMeshDocumentSchemaDescriptor = CultMeshDocumentSchemaDescriptor>(
schema: TSchema,
): CultMeshCollectionHandle<CultMeshCatalogValue<TDocument, TSchema>> | undefined {
const descriptor = normalizeCultMeshDocumentSchema(schema);
// One type owns a schema, so a typed lookup is answered by that type's handle or not at all.
if (descriptor.type) {
@ -1709,30 +1711,34 @@ export class CultMeshCollectionCatalog {
(key ? this.#bySchemaNameVersion.get(key) : undefined);
}
public collection<TDocument>(
schema: CultMeshDocumentSchemaDescriptor,
): CultMeshCollectionHandle<TDocument> {
const collection = this.tryCollection<TDocument>(schema);
public collection<TDocument = never, TSchema extends CultMeshDocumentSchemaDescriptor = CultMeshDocumentSchemaDescriptor>(
schema: TSchema,
): CultMeshCollectionHandle<CultMeshCatalogValue<TDocument, TSchema>> {
return this.#require(schema);
}
public latest<TDocument = never, TSchema extends CultMeshDocumentSchemaDescriptor = CultMeshDocumentSchemaDescriptor>(
schema: TSchema,
context: CultMeshQueryContext | string = "local",
): Promise<CultMeshCollectionSnapshot<CultMeshCatalogValue<TDocument, TSchema>>> {
return this.#require(schema).latest(context);
}
public watchChanges<TDocument = never, TSchema extends CultMeshDocumentSchemaDescriptor = CultMeshDocumentSchemaDescriptor>(
schema: TSchema,
callback: (change: CultMeshCollectionChange<CultMeshCatalogValue<TDocument, TSchema>>) => void,
options: { context?: CultMeshQueryContext | string } = {},
): CultMeshUnsubscribe {
return this.#require(schema).watchChanges(options.context ?? "local", callback);
}
#require(schema: CultMeshDocumentSchemaDescriptor): CultMeshCollectionHandle<any> {
const collection = this.tryCollection(schema);
if (!collection) {
throw new Error(`Collection catalog has no collection for ${cultMeshSchemaLabel(schema)}.`);
}
return collection;
}
public latest<TDocument>(
schema: CultMeshDocumentSchemaDescriptor,
context: CultMeshQueryContext | string = "local",
): Promise<CultMeshCollectionSnapshot<TDocument>> {
return this.collection<TDocument>(schema).latest(context);
}
public watchChanges<TDocument>(
schema: CultMeshDocumentSchemaDescriptor,
callback: (change: CultMeshCollectionChange<TDocument>) => void,
options: { context?: CultMeshQueryContext | string } = {},
): CultMeshUnsubscribe {
return this.collection<TDocument>(schema).watchChanges(options.context ?? "local", callback);
}
}
export class CultMeshBoundCollectionHandle<TDocument> {
@ -2308,6 +2314,17 @@ function resolveCultMeshVerseContext(verse: CultMeshVerseContext | CultMeshVerse
return verse instanceof CultMeshVerse ? verse.context : verse;
}
// A catalog write takes (value, options?) or (context, value); both resolve to one context and one value.
function cultMeshCatalogWriteArguments(
contextOrValue: unknown,
valueOrOptions: unknown,
): [CultMeshQueryContext | string, unknown] {
if (typeof contextOrValue === "string" || isCultMeshQueryContext(contextOrValue)) {
return [contextOrValue, valueOrOptions];
}
return [(valueOrOptions as { context?: CultMeshQueryContext | string } | undefined)?.context ?? "local", contextOrValue];
}
function isCultMeshQueryContext(value: unknown): value is CultMeshQueryContext {
if (typeof value !== "object" || value === null) {
return false;

View file

@ -135,7 +135,7 @@ test("CultMesh TS document handles hide local cache plumbing behind typed reacti
body: "catalog-updated",
});
assert.equal(
(await catalog.latest<Note>(noteDocument, "browser-client")).body,
(await catalog.latest(noteDocument, "browser-client")).body,
"catalog-updated",
);
// A typed lookup is answered by its own type's handle; a handle of another type for the same schema is not it.
@ -150,6 +150,41 @@ test("CultMesh TS document handles hide local cache plumbing behind typed reacti
assert.equal((await document.latest()).body, "catalog-updated:updated-through-bound-set");
});
test("CultMesh TS catalogs infer a lookup's value type from its document definition", async () => {
const documents = CultMesh.documents(CultMesh.document("note:typed", noteDocument, async () => ({ noteId: "note:typed", body: "typed" })));
const collections = CultMesh.collections(CultMesh.collection("notes:typed", noteDocument, async () => [{ noteId: "note:typed", body: "typed" }]));
const inferred: Note = await documents.latest(noteDocument);
const inferredRows: readonly Note[] = await collections.latest(noteDocument);
assert.equal(inferred.body, "typed");
assert.equal(inferredRows[0]?.body, "typed");
// @ts-expect-error a definition names its value type: body is a string
const wrong: number = (await documents.latest(noteDocument)).body;
// @ts-expect-error a collection lookup infers its rows the same way
const wrongRows: readonly number[] = await collections.latest(noteDocument);
// @ts-expect-error a bare schema descriptor yields unknown
void (await documents.latest({ schemaId: "cultmesh.note.v0" })).body;
const explicit: { body: string } = await documents.latest<{ body: string }>({ schemaId: "cultmesh.note.v0" });
assert.equal(explicit.body, "typed");
void wrong;
void wrongRows;
// Writes are checked against the definition's value type. Never called: the handle is read-only.
const typeOnly = () => {
// @ts-expect-error replace checks the value against the definition
void documents.replace(noteDocument, { noteId: "note:typed", body: 1 });
// @ts-expect-error so does a prediction, with a context
void documents.submitPrediction(noteDocument, "local", { noteId: "note:typed" });
documents.watch(noteDocument, value => {
// @ts-expect-error a watched value is the definition's value
const n: number = value.body;
void n;
});
// @ts-expect-error a writer writes the definition's value
void documents.authoritativeWriter(noteDocument).write({ noteId: 1, body: "x" });
};
void typeOnly;
});
test("CultMesh TS document handles submit predictions through configured authority hooks", async () => {
let current = {
noteId: "note:prediction",
@ -941,7 +976,7 @@ test("CultMesh TS binds publication document catalogs from source resolvers", as
["daemon:first", "daemon:second"],
);
assert.equal(
(await catalog.document<Note>(noteDocument).latest()).body,
(await catalog.document(noteDocument).latest()).body,
"second source",
);
assert.equal(catalog.document(noteDocument).routeHint.description, "publication catalog");
@ -1117,7 +1152,7 @@ test("CultMesh TS collection handles expose typed snapshots and reset watches",
const catalog = CultMesh.collections(collection);
assert.deepEqual(
(await catalog.latest<Note>(noteDocument, "local")).map(note => note.body).sort(),
(await catalog.latest(noteDocument, "local")).map(note => note.body).sort(),
["alpha", "bravo"],
);
// A typed lookup is answered by its own type's handle; a handle of another type for the same schema is not it.